Merge remote-tracking branch 'origin/main'

This commit is contained in:
tao.chen
2026-07-27 17:40:49 +08:00
16 changed files with 2930 additions and 249 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:
+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 过滤。
---
+164 -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,9 +153,9 @@ 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)
@@ -105,6 +163,104 @@ class OpenCodeClient:
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,
+187 -18
View File
@@ -49,7 +49,7 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
def _build_request_body(prompt: str, context: dict) -> dict:
"""Build full request body for OpenCode POST /session/:id/message.
"""Build full request body for OpenCode POST /session/:id/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
@@ -105,6 +105,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 +174,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:
@@ -178,44 +205,174 @@ class EditHandler(APIHandler):
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(prompt, context)
result = await client.send_message_sync(
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):
@@ -310,9 +467,21 @@ 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),
# /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)
+121 -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,39 @@ 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_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
+191 -19
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,6 +57,19 @@ class FakeOpenCodeClient:
self.calls.append(("list_session_messages", session_id))
return self.messages_response
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:
"""Drop-in replacement for SessionManager with recording."""
@@ -131,6 +144,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,31 +178,68 @@ async def test_edit_handler(monkeypatch, jp_fetch):
)
assert response.code == 200
payload = json.loads(response.body)
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 payload["notebookPath"] == "test.ipynb"
assert payload == {
"ok": True,
"sessionId": "fake-session-123",
"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]
# send_message_async received the session ID from the manager.
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
assert send_call[1] == "fake-session-123"
# system prompt was passed (v3+ allows markdown/fences in the reply)
# 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.
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"
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 = {
@@ -287,3 +343,119 @@ 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
+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();
});
});
+704 -33
View File
@@ -103,13 +103,22 @@ beforeAll(() => {
});
});
beforeEach(() => {
// Each test starts with a clean selection store so persistence tests
// are deterministic.
localStorage.clear();
});
// 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: [] })
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
callOpenCodeReplyQuestion: jest.fn().mockResolvedValue({ ok: true }),
subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() })
}));
import {
@@ -119,6 +128,20 @@ import {
} from '../components/opencode_cell_actions';
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
import { CodeCell } from '@jupyterlab/cells';
import {
callOpenCodeEdit,
subscribeOpenCodeEvents
} from '../api/opencode_client';
import { Notification } from '@jupyterlab/apputils';
const mockedCallOpenCodeEdit = callOpenCodeEdit as jest.MockedFunction<
typeof callOpenCodeEdit
>;
const mockedSubscribeOpenCodeEvents =
subscribeOpenCodeEvents as jest.MockedFunction<
typeof subscribeOpenCodeEvents
>;
const mockedNotification = Notification as jest.Mocked<typeof Notification>;
function makeFakeOutputs(items: any[]): any {
return {
@@ -306,6 +329,122 @@ describe('OpenCodeCellActions', () => {
setOpenCodeProviders(null);
});
it('restores the user\'s previously-saved (provider, model) when still valid in the providers payload', () => {
// Seed localStorage with a prior selection that IS valid.
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'openai', modelId: 'gpt-5' })
);
setOpenCodeProviders({
providers: [
{ id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } },
{ id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } }
],
default: { anthropic: 'claude-sonnet-4-20250514' }
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const prompt = (actions as any)._prompt;
const providerSel = prompt.node.querySelector(
'.opencode-provider-select'
) as HTMLSelectElement;
const modelSel = prompt.node.querySelector(
'.opencode-model-select'
) as HTMLSelectElement;
// Stored selection was applied, NOT the default (first provider).
expect(providerSel.value).toBe('openai');
expect(modelSel.value).toBe('gpt-5');
setOpenCodeProviders(null);
});
it('falls back to default when the stored provider is no longer present', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'removed-provider', modelId: 'm1' })
);
setOpenCodeProviders({
providers: [
{ id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } }
]
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const providerSel = (actions as any)._prompt.node.querySelector(
'.opencode-provider-select'
) as HTMLSelectElement;
// Provider is gone → fall back to first available.
expect(providerSel.value).toBe('anthropic');
setOpenCodeProviders(null);
});
it('falls back to default model when the stored model is no longer present under the same provider', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'openai', modelId: 'gpt-99-removed' })
);
setOpenCodeProviders({
providers: [
{ id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } }
],
default: { openai: 'gpt-5' }
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const modelSel = (actions as any)._prompt.node.querySelector(
'.opencode-model-select'
) as HTMLSelectElement;
// Model is gone → fall back to default[openai] = gpt-5.
expect(modelSel.value).toBe('gpt-5');
setOpenCodeProviders(null);
});
it('saves the new selection to localStorage when the model select changes', () => {
setOpenCodeProviders({
providers: [
{ id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } },
{ id: 'openai', models: { 'gpt-5': { id: 'x' } } }
]
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const prompt = (actions as any)._prompt;
const providerSel = prompt.node.querySelector(
'.opencode-provider-select'
) as HTMLSelectElement;
const modelSel = prompt.node.querySelector(
'.opencode-model-select'
) as HTMLSelectElement;
// Switch provider to openai (whose model list includes gpt-5).
providerSel.value = 'openai';
providerSel.dispatchEvent(new Event('change'));
// Now change the model select to gpt-5.
modelSel.value = 'gpt-5';
modelSel.dispatchEvent(new Event('change'));
const stored = JSON.parse(
localStorage.getItem('opencode_bridge:model-selection:foo.ipynb') || 'null'
);
expect(stored).toEqual({ providerId: 'openai', modelId: 'gpt-5' });
setOpenCodeProviders(null);
});
it('falls back to the first model when default[provider] is not in models', () => {
setOpenCodeProviders({
providers: [
@@ -395,58 +534,197 @@ describe('OpenCodeCellActions', () => {
).toBeNull();
});
it('on a successful response, does NOT replace the cell source and triggers a history refresh', () => {
it('on submit: posts to /edit, binds SSE, does NOT replace cell source, clears input', async () => {
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({
mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
markdown: '# AI says hi\n\n```python\nprint("hi")\n```',
sessionId: 'ses-abc',
notebookPath: 'foo.ipynb'
});
const subscribeCalls: any[] = [];
mockedSubscribeOpenCodeEvents.mockImplementation(
((sid: string, _s: any, handlers: any) => {
subscribeCalls.push({ sid, handlers });
return { close: jest.fn() };
}) as any
);
// 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();
// Type into the textarea, then submit.
const prompt = (actions as any)._prompt;
expect(prompt).not.toBeNull();
const textarea = prompt._textarea as HTMLTextAreaElement;
textarea.value = 'make it faster';
(prompt._sendBtn as HTMLButtonElement).click();
// 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({
// Let the async _onSubmit run.
await new Promise(r => setTimeout(r, 5));
// callOpenCodeEdit was called with the right body.
expect(mockedCallOpenCodeEdit).toHaveBeenCalledTimes(1);
expect(mockedCallOpenCodeEdit.mock.calls[0][0].prompt).toBe('make it faster');
expect(mockedCallOpenCodeEdit.mock.calls[0][0].context.notebookPath).toBe('foo.ipynb');
// SSE subscription was opened with the returned sessionId.
expect(subscribeCalls).toHaveLength(1);
expect(subscribeCalls[0].sid).toBe('ses-abc');
expect(typeof subscribeCalls[0].handlers.onEvent).toBe('function');
// The cell source must remain unchanged.
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
// The textarea is cleared so the user can type a follow-up.
expect(textarea.value).toBe('');
});
it('on submit: forwards SSE events to the prompt and closes the stream on session.idle', async () => {
setOpenCodeProviders(null);
mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
markdown: 'ok',
sessionId: 'ses-1',
notebookPath: 'foo.ipynb'
});
expect(textarea.value).toBe('');
const fakeSse = { close: jest.fn() };
let captured: any = null;
mockedSubscribeOpenCodeEvents.mockImplementation(
((_sid: string, _s: any, handlers: any) => {
captured = handlers;
return fakeSse;
}) as any
);
const cell = makeFakeCell('x = 1');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
aiBtn.click();
const prompt = (actions as any)._prompt;
const textarea = prompt._textarea as HTMLTextAreaElement;
textarea.value = 'go';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
expect(captured).not.toBeNull();
// Simulate a text-delta event arriving.
const applySpy = jest.spyOn(prompt, 'applyEvent');
captured.onEvent({
type: 'session.next.text.delta',
properties: { sessionID: 'ses-1', delta: 'hel' }
});
expect(applySpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'session.next.text.delta' })
);
// session.idle should close the stream and re-enable the input.
captured.onEvent({ type: 'session.idle', properties: { sessionID: 'ses-1' } });
expect(fakeSse.close).toHaveBeenCalled();
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
});
it('on submit: session.status with status.type=idle also closes the stream (idle shape variants)', async () => {
setOpenCodeProviders(null);
mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
sessionId: 'ses-1',
notebookPath: 'foo.ipynb'
});
const fakeSse = { close: jest.fn() };
let captured: any = null;
mockedSubscribeOpenCodeEvents.mockImplementation(
((_sid: string, _s: any, handlers: any) => {
captured = handlers;
return fakeSse;
}) as any
);
const cell = makeFakeCell('x = 1');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const prompt = (actions as any)._prompt;
(prompt._textarea as HTMLTextAreaElement).value = 'go';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
// session.status with status.type === 'idle' should also close the
// stream (some OpenCode versions emit this shape instead of
// session.idle).
captured.onEvent({
type: 'session.status',
properties: { sessionID: 'ses-1', status: { type: 'idle' } }
});
expect(fakeSse.close).toHaveBeenCalled();
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
});
it('on submit: idle nested under payload also closes the stream', async () => {
setOpenCodeProviders(null);
mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
sessionId: 'ses-1',
notebookPath: 'foo.ipynb'
});
const fakeSse = { close: jest.fn() };
let captured: any = null;
mockedSubscribeOpenCodeEvents.mockImplementation(
((_sid: string, _s: any, handlers: any) => {
captured = handlers;
return fakeSse;
}) as any
);
const cell = makeFakeCell('x = 1');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const prompt = (actions as any)._prompt;
(prompt._textarea as HTMLTextAreaElement).value = 'go';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
// Some OpenCode events have type at top level AND in payload.
captured.onEvent({
type: 'unknown.wrapper',
payload: {
type: 'session.idle',
properties: { sessionID: 'ses-1' }
}
});
expect(fakeSse.close).toHaveBeenCalled();
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
});
it('on submit failure: re-enables the input and shows an error notification', async () => {
setOpenCodeProviders(null);
mockedCallOpenCodeEdit.mockResolvedValue({
ok: false,
error: 'opencode down'
});
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 aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
aiBtn.click();
const prompt = (actions as any)._prompt;
const textarea = prompt._textarea as HTMLTextAreaElement;
textarea.value = 'go';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
expect(mockedNotification.error).toHaveBeenCalledWith('OpenCode 错误: opencode down');
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
});
});
@@ -674,4 +952,397 @@ describe('OpenCodeInlinePrompt', () => {
);
expect(onSubmit).not.toHaveBeenCalled();
});
it('applyEvent: text deltas accumulate into a single assistant element (real-time streaming)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
// Many deltas — they must all append to the SAME DOM node, not
// create a new one each time (that would defeat real-time rendering).
const deltas = ['hel', 'lo', ' ', 'wor', 'ld', '!', ' ', 'It works.'];
for (const d of deltas) {
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: d }
});
}
const all = prompt.node.querySelectorAll(
'.opencode-inline-prompt .opencode-msg-assistant'
);
// Exactly one element — the streaming text is appended, not forked.
expect(all.length).toBe(1);
expect(all[0].textContent).toBe('hello world! It works.');
});
it('applyEvent: unrecognized event types are logged but never throw', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
prompt.setSessionId('s1');
// An event type the dispatcher doesn't recognize.
expect(() =>
prompt.applyEvent({
type: 'some.future.event',
properties: { sessionID: 's1', payload: 'whatever' }
})
).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('unrecognized SSE event type'),
'some.future.event',
expect.anything()
);
warnSpy.mockRestore();
});
it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => {
// Regression: previously the streaming path appended deltas to
// .textContent, so the user saw raw ```py\n...\n``` source while
// the model was still typing. Only after closing+reopening the
// panel (which goes through setMessages + marked.parse) did the
// code block render as <pre><code>. The fix: re-render via
// marked.parse on every delta.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
// Simulate a streaming reply that contains a fenced code block.
const deltas = [
'Here is the fix:\n\n',
'```python\n',
'import pandas as pd\n',
'import numpy as np\n',
'```\n'
];
for (const d of deltas) {
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: d }
});
}
// The <pre><code> block is rendered DURING streaming, not just at
// the end. The raw ```fence``` characters must NOT appear as visible
// text content in the message body.
const pre = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-msg-content pre'
) as HTMLElement;
expect(pre).not.toBeNull();
const code = pre.querySelector('code') as HTMLElement;
expect(code).not.toBeNull();
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
// The Copy / Insert / Replace toolbar is attached to the <pre>
// block during streaming, same as in setMessages.
const toolbar = pre.parentElement!.querySelector(
'.opencode-code-toolbar'
) as HTMLElement;
expect(toolbar).not.toBeNull();
expect(toolbar.querySelectorAll('button').length).toBe(3);
// The wrapper still has a single assistant element (not one per delta).
expect(
prompt.node.querySelectorAll(
'.opencode-inline-prompt .opencode-msg-assistant'
).length
).toBe(1);
});
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'message.part.delta',
properties: { sessionID: 's1', field: 'text', delta: 'world' }
});
const asst = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-msg-assistant'
) as HTMLElement;
expect(asst.textContent).toBe('world');
});
it('applyEvent: reasoning deltas render into a collapsible green block', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'session.next.reasoning.delta',
properties: { sessionID: 's1', delta: 'thinking...' }
});
const block = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-msg-block-reasoning'
) as HTMLElement;
expect(block).not.toBeNull();
const content = block.querySelector(
'.opencode-msg-block-content'
) as HTMLElement;
expect(content.textContent).toBe('thinking...');
});
it('applyEvent: tool-call lifecycle creates and updates a tool block', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'session.next.tool.called',
properties: { sessionID: 's1', name: 'bash' }
});
prompt.applyEvent({
type: 'session.next.tool.input.delta',
properties: { sessionID: 's1', delta: '{"cmd":' }
});
prompt.applyEvent({
type: 'session.next.tool.success',
properties: { sessionID: 's1', result: { ok: true } }
});
const block = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-msg-block-tool'
) as HTMLElement;
expect(block).not.toBeNull();
const summary = block.querySelector('summary') as HTMLElement;
expect(summary.textContent).toContain('bash');
const content = block.querySelector(
'.opencode-msg-block-content'
) as HTMLElement;
expect(content.textContent).toContain('{"cmd":');
expect(content.textContent).toContain('"ok": true');
});
it('applyEvent: session.idle resets stream pointers (next text delta starts a fresh assistant element)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: 'turn1' }
});
prompt.applyEvent({
type: 'session.idle',
properties: { sessionID: 's1' }
});
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: 'turn2' }
});
const asstBlocks = prompt.node.querySelectorAll(
'.opencode-inline-prompt .opencode-msg-assistant'
);
expect(asstBlocks.length).toBe(2);
expect(asstBlocks[0].textContent).toBe('turn1');
expect(asstBlocks[1].textContent).toBe('turn2');
});
it('applyEvent: drops events for a different session (defensive filter)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's2', delta: 'should be ignored' }
});
const asst = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-msg-assistant'
);
expect(asst).toBeNull();
});
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'permission.asked',
properties: { sessionID: 's1', id: 'perm-1', description: 'rm -rf' }
});
const block = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-msg-block-interaction'
) as HTMLElement;
expect(block).not.toBeNull();
expect(block.id).toBe('opencode-perm-perm-1');
expect(block.textContent).toContain('rm -rf');
// Three buttons: once / always / reject.
const buttons = block.querySelectorAll('button');
expect(buttons.length).toBe(3);
expect(buttons[0].textContent).toContain('Once');
expect(buttons[1].textContent).toContain('Always');
expect(buttons[2].textContent).toContain('Reject');
});
it('applyEvent: clicking a permission button calls onPermissionReply and shows status', async () => {
const cell = makeFakeCell('x = 1');
const onPermissionReply = jest.fn().mockResolvedValue(true);
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
onPermissionReply
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'permission.asked',
properties: { sessionID: 's1', id: 'perm-9', description: 'sudo' }
});
const rejectBtn = prompt.node.querySelector(
'.opencode-perm-btn-reject'
) as HTMLButtonElement;
rejectBtn.click();
await new Promise(r => setTimeout(r, 5));
expect(onPermissionReply).toHaveBeenCalledWith('perm-9', 'reject');
// The button group now shows the success status.
const block = prompt.node.querySelector(
'#opencode-perm-perm-9'
) as HTMLElement;
expect(block.textContent).toContain('已拒绝');
});
it('applyEvent: question.asked creates a question block with input + submit', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'question.asked',
properties: { sessionID: 's1', id: 'q-1', question: 'which env?' }
});
const block = prompt.node.querySelector(
'#opencode-q-q-1'
) as HTMLElement;
expect(block).not.toBeNull();
expect(block.textContent).toContain('which env?');
expect(block.querySelector('input.opencode-q-input')).not.toBeNull();
expect(block.querySelector('button.opencode-q-submit')).not.toBeNull();
});
it('applyEvent: question submit calls onQuestionReply with the typed answer', async () => {
const cell = makeFakeCell('x = 1');
const onQuestionReply = jest.fn().mockResolvedValue(true);
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
onQuestionReply
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'question.asked',
properties: { sessionID: 's1', id: 'q-2', question: 'which version?' }
});
const input = prompt.node.querySelector(
'.opencode-q-input'
) as HTMLInputElement;
const submit = prompt.node.querySelector(
'.opencode-q-submit'
) as HTMLButtonElement;
input.value = 'use 3.12';
submit.click();
await new Promise(r => setTimeout(r, 5));
expect(onQuestionReply).toHaveBeenCalledWith('q-2', 'use 3.12');
const block = prompt.node.querySelector('#opencode-q-q-2') as HTMLElement;
expect(block.textContent).toContain('已回答');
});
it('applyEvent: question submit with empty input is a no-op', async () => {
const cell = makeFakeCell('x = 1');
const onQuestionReply = jest.fn();
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
onQuestionReply
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'question.asked',
properties: { sessionID: 's1', id: 'q-3', question: 'color?' }
});
const submit = prompt.node.querySelector(
'.opencode-q-submit'
) as HTMLButtonElement;
submit.click();
await new Promise(r => setTimeout(r, 5));
expect(onQuestionReply).not.toHaveBeenCalled();
});
it('applyEvent: system events (server.* etc.) are NOT rendered', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
const systemTypes = [
'server.connected',
'workspace.updated',
'lsp.updated',
'mcp.tools.changed',
'pty.created',
'session.next.agent.switched',
'session.next.model.switched',
'file.edited'
];
for (const t of systemTypes) {
prompt.applyEvent({ type: t, properties: { sessionID: 's1' } });
}
// No system lines, no message blocks, nothing rendered.
expect(
prompt.node.querySelector('.opencode-msg-system')
).toBeNull();
expect(
prompt.node.querySelectorAll('.opencode-msg').length
).toBe(0);
expect(
prompt.node.querySelectorAll('.opencode-msg-block').length
).toBe(0);
});
});
+170 -4
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', () => {
@@ -82,10 +88,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 +112,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 +227,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);
});
});
+249 -6
View File
@@ -1,19 +1,32 @@
/**
* 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 {
OpenCodeEditResponse,
OpenCodeEvent,
OpenCodeMessagesResponse,
OpenCodeProvidersResponse,
OpenCodeRequest
} 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 +36,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 +64,7 @@ export async function callOpenCodeEdit(
);
}
return parsed as OpenCodeResponse;
return parsed as OpenCodeEditResponse;
}
/**
@@ -115,3 +128,233 @@ 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);
}
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
}
}
+114 -41
View File
@@ -1,14 +1,20 @@
/**
* 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.
* (top-right of the active cell). 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).
* v4 message flow (matches demo.html):
* 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
* 2. User submits prompt -> callOpenCodeEdit returns immediately with
* sessionId
* 3. We subscribe to GET /opencode-bridge/events?session=<sid> and
* forward each event to the prompt's applyEvent() dispatcher
* 4. On session.idle we close the stream and re-enable the input
*
* The frontend does NO semantic processing of the LLM reply. The chosen
* provider/model comes from the inline picker (no settings-based default).
*/
import type { CodeCell } from '@jupyterlab/cells';
import { Notification } from '@jupyterlab/apputils';
@@ -16,13 +22,18 @@ import type { NotebookPanel } from '@jupyterlab/notebook';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
import {
callOpenCodeEdit,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents,
type OpenCodeEventSubscription
} from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
CellContext,
OpenCodeProvidersResponse,
OpenCodeRequest,
OpenCodeResponse
OpenCodeProvidersResponse
} from '../types';
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
@@ -50,6 +61,7 @@ export class OpenCodeCellActions extends Widget {
private _context: CellContext | null = null;
private _status: Status = 'idle';
private _prompt: OpenCodeInlinePrompt | null = null;
private _sseSub: OpenCodeEventSubscription | null = null;
constructor(cell: CodeCell) {
super();
@@ -65,6 +77,7 @@ export class OpenCodeCellActions extends Widget {
if (this.isDisposed) {
return;
}
this._closeSse();
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
this._hidePrompt();
@@ -114,19 +127,67 @@ export class OpenCodeCellActions extends Widget {
const prompt = new OpenCodeInlinePrompt(this._cell, {
disabled: !this._context || this._status === 'loading',
providers: _providers,
notebookPath: this._context?.notebookPath,
onSubmit: (text: string, providerId?: string, modelId?: string) => {
void this._onSubmit(text, providerId, modelId);
},
onCancel: () => {
this._hidePrompt();
},
onPermissionReply: async (permId, response) => {
if (!_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,
_serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: permission reply failed', e);
return false;
}
},
onQuestionReply: async (qId, answer) => {
if (!_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,
_serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: question reply failed', e);
return false;
}
},
onStreamEnd: () => {
// Centralized: the prompt detected "idle" (any shape) and tells
// us to close the SSE stream + restore the input. We no longer
// parse event types here.
this._closeSse();
}
});
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.
// Fetch the current session's static history and render it before
// streaming begins. Empty list is a normal no-op.
const notebookPath = this._context?.notebookPath;
if (notebookPath) {
void this._refreshHistory(notebookPath);
@@ -144,8 +205,6 @@ export class OpenCodeCellActions extends Widget {
);
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);
}
}
@@ -170,7 +229,7 @@ export class OpenCodeCellActions extends Widget {
return;
}
const request: OpenCodeRequest = {
const request = {
prompt: text,
context: this._context,
providerId,
@@ -180,41 +239,55 @@ export class OpenCodeCellActions extends Widget {
this._status = 'loading';
if (this._prompt) {
this._prompt.setDisabled(true);
this._prompt.setSessionId(null);
}
let resp;
try {
const resp = await callOpenCodeEdit(request, _serverSettings);
this._handleResponse(resp);
resp = await callOpenCodeEdit(request, _serverSettings);
} catch (e) {
this._status = 'idle';
if (this._prompt) {
this._prompt.setDisabled(false);
}
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;
}
// Async edit accepted. Bind the prompt to the returned session, clear
// the input, then subscribe to the SSE event stream for this session.
this._prompt?.setSessionId(resp.sessionId);
this._prompt?.clearInput();
this._sseSub = subscribeOpenCodeEvents(
resp.sessionId,
_serverSettings,
{
onEvent: ev => {
this._prompt?.applyEvent(ev);
// Stream-end is detected by the prompt and signalled via
// onStreamEnd (see _showPrompt); no idle parsing here.
},
onError: err => {
console.warn('opencode_bridge: SSE error', err);
// Don't flip status here: the server may just have hiccuped.
// session.idle (if it comes through) will re-enable the input.
}
}
);
}
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}`);
private _closeSse(): void {
if (this._sseSub) {
this._sseSub.close();
this._sseSub = null;
}
this._status = 'idle';
this._prompt?.setDisabled(false);
}
}
+660 -94
View File
@@ -1,26 +1,70 @@
/**
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
* Renders two <select> boxes (provider + model), a textarea, send/cancel
* buttons, and a scrollable history area that shows the current session's
* messages (user + assistant). The model select's default for the chosen
* provider is `default[providerID]` from the /config/providers response.
*
* v3-final: no settings picks come from the OpenCode Serve providers
* cache; history comes from /session-messages; cell source is NOT replaced
* (the AI reply is rendered as markdown inside this history area).
* Renders two <select> boxes (provider + model), a textarea, send/cancel
* buttons, and a scrollable history area. The history area serves two modes:
*
* 1. **Static** `setMessages(messages)` renders the session's stored
* history (user/assistant markdown) when the panel is first opened.
* 2. **Streaming** after the user submits a prompt, the owning cell
* action subscribes to `/opencode-bridge/events` and forwards every
* `OpenCodeEvent` to `applyEvent(event)`, which dispatches to the
* appropriate DOM helper (text deltas, reasoning blocks, tool blocks,
* permission/question prompts, system lines). On `session.idle` the
* stream pointers reset.
*
* The model select's default for the chosen provider is `default[providerID]`
* from the /config/providers response.
*
* v4: cell source is NOT replaced. The AI reply is rendered into this
* history area. The user can still Copy / Insert / Replace individual
* code blocks from a finished assistant message.
*/
import { CodeCell } from '@jupyterlab/cells';
import { Notification } from '@jupyterlab/apputils';
import { Widget } from '@lumino/widgets';
import { marked } from 'marked';
import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
import type {
OpenCodeEvent,
OpenCodeMessage,
OpenCodeProvidersResponse
} from '../types';
import {
loadModelSelection,
saveModelSelection
} from './model_selection';
export interface IOpenCodeInlinePromptOptions {
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
onCancel: () => void;
disabled: boolean;
providers: OpenCodeProvidersResponse | null;
/**
* Notebook path used to key the persisted (provider, model) selection
* in localStorage. If omitted (or the stored selection is no longer
* valid in the current `providers` payload), the prompt falls back
* to the default selection (first provider / `default[pid]` model).
*/
notebookPath?: string;
/**
* Respond to a `permission.asked` event. Returns a promise that resolves
* to true on success, false on failure (the prompt updates the UI in
* both cases).
*/
onPermissionReply?: (
permissionId: string,
response: 'once' | 'always' | 'reject'
) => Promise<boolean>;
/** Reply to a `question.asked` event with the user's freeform answer. */
onQuestionReply?: (questionId: string, answer: string) => Promise<boolean>;
/**
* Fired when the prompt detects the agent has finished its turn
* (any of: `session.idle`, `session.status` with `status.type === 'idle'`,
* or the same shapes nested under `payload`). Centralizing this in
* the prompt keeps the cell action from re-parsing event types.
*/
onStreamEnd?: () => void;
}
interface FlatProvider {
@@ -65,8 +109,14 @@ function pickDefaultModelId(
return keys[0];
}
interface BlockEntry {
details: HTMLDetailsElement;
content: HTMLDivElement;
}
export class OpenCodeInlinePrompt extends Widget {
private _cell: CodeCell;
private _options: IOpenCodeInlinePromptOptions;
private _textarea: HTMLTextAreaElement;
private _sendBtn: HTMLButtonElement;
private _cancelBtn: HTMLButtonElement;
@@ -76,18 +126,40 @@ export class OpenCodeInlinePrompt extends Widget {
private _defaultMap: { [pid: string]: string };
private _historyEl: HTMLDivElement;
// Streaming state. _sessionId is set by the cell action after a successful
// /edit response; applyEvent() drops events that don't belong to it.
private _sessionId: string | null = null;
// The current assistant message DOM node (re-rendered as markdown on
// every text delta so the user sees a properly formatted response in
// real-time, not raw ```fence``` source).
private _currentAssistantEl: HTMLDivElement | null = null;
// Accumulator for the current assistant turn's raw markdown source.
// Each text delta appends here; the whole string is then re-rendered
// through marked.parse to refresh the DOM. Using the raw source as
// truth (not innerHTML) avoids lossy round-trips through partial HTML.
private _currentAssistantText: string = '';
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
private _activeBlocks: { [key: string]: BlockEntry } = {};
constructor(
cell: CodeCell,
options: IOpenCodeInlinePromptOptions
) {
super();
this._cell = cell;
this._options = options;
this.addClass('opencode-inline-prompt');
const flat = flattenProviders(options.providers);
this._providerModels = flat.providers;
this._defaultMap = flat.defaultMap;
// Resolve the initial selection: prefer a previously-saved
// (providerId, modelId) IF it's still valid in the current
// providers payload; otherwise fall back to the default
// (first provider / `default[pid]` model).
const stored = this._resolveStoredSelection(options.notebookPath);
if (this._providerModels.length > 0) {
this._providerSelect = document.createElement('select');
this._providerSelect.className = 'opencode-provider-select';
@@ -97,16 +169,25 @@ export class OpenCodeInlinePrompt extends Widget {
o.textContent = p.name ? `${p.id} (${p.name})` : p.id;
this._providerSelect.appendChild(o);
}
this._providerSelect.value = this._providerModels[0].id;
this._providerSelect.value = stored
? stored.providerId
: this._providerModels[0].id;
this._providerSelect.addEventListener('change', () => {
this._rebuildModelSelect(this._providerSelect!.value);
this._persistSelection();
});
}
if (this._providerModels.length > 0) {
this._modelSelect = document.createElement('select');
this._modelSelect.className = 'opencode-model-select';
this._rebuildModelSelect(this._providerSelect?.value);
this._rebuildModelSelect(
this._providerSelect?.value,
stored ? stored.modelId : undefined
);
this._modelSelect.addEventListener('change', () => {
this._persistSelection();
});
}
this._textarea = document.createElement('textarea');
@@ -125,8 +206,6 @@ export class OpenCodeInlinePrompt extends Widget {
const modelId = this._modelSelect?.value || undefined;
options.onSubmit(text, providerId, modelId);
});
// Chat-input convention: Enter submits, Shift+Enter / Ctrl+Enter
// insert a newline (textarea default).
this._textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
@@ -167,8 +246,8 @@ export class OpenCodeInlinePrompt extends Widget {
this.node.appendChild(row);
}
// Scrollable history of the current session's messages. Empty until
// setMessages() is called by the owning cell action.
// Scrollable history (static + streaming). Empty until setMessages() /
// applyEvent() populate it.
this._historyEl = document.createElement('div');
this._historyEl.className = 'opencode-inline-history';
this.node.appendChild(this._historyEl);
@@ -177,7 +256,10 @@ export class OpenCodeInlinePrompt extends Widget {
this.node.appendChild(actions);
}
private _rebuildModelSelect(providerId: string | undefined): void {
private _rebuildModelSelect(
providerId: string | undefined,
preferredModelId?: string
): void {
if (!this._modelSelect) {
return;
}
@@ -205,8 +287,65 @@ export class OpenCodeInlinePrompt extends Widget {
this._modelSelect.appendChild(o);
}
this._modelSelect.disabled = false;
this._modelSelect.value =
pickDefaultModelId(providerId, provider.models, this._defaultMap) ?? keys[0];
// Honor a preferred model (e.g. from a stored selection), but only
// if it actually exists in this provider's model list. Otherwise
// fall back to the default.
if (
preferredModelId &&
Object.prototype.hasOwnProperty.call(provider.models, preferredModelId)
) {
this._modelSelect.value = preferredModelId;
} else {
this._modelSelect.value =
pickDefaultModelId(providerId, provider.models, this._defaultMap) ??
keys[0];
}
}
/**
* Read the stored (providerId, modelId) for this notebook and
* validate it against the current providers payload. Returns the
* stored selection only when BOTH the provider and the model still
* exist; otherwise returns null and the caller uses the default.
*/
private _resolveStoredSelection(
notebookPath: string | undefined
): { providerId: string; modelId: string } | null {
if (!notebookPath) {
return null;
}
const sel = loadModelSelection(notebookPath);
if (!sel) {
return null;
}
const provider = this._providerModels.find(p => p.id === sel.providerId);
if (!provider) {
// The provider was removed since the user last picked.
return null;
}
if (!Object.prototype.hasOwnProperty.call(provider.models, sel.modelId)) {
// The model was removed (or renamed) under a still-valid provider.
return null;
}
return sel;
}
/**
* Persist the current (providerSelect.value, modelSelect.value) for
* this notebook. No-op if the prompt has no notebookPath, no
* selects, or the values are empty.
*/
private _persistSelection(): void {
const notebookPath = this._options.notebookPath;
if (!notebookPath || !this._providerSelect || !this._modelSelect) {
return;
}
const providerId = this._providerSelect.value;
const modelId = this._modelSelect.value;
if (!providerId || !modelId) {
return;
}
saveModelSelection(notebookPath, { providerId, modelId });
}
setDisabled(disabled: boolean): void {
@@ -219,20 +358,27 @@ export class OpenCodeInlinePrompt extends Widget {
}
}
/**
* Clear the user input textarea (called by the cell action after a
* successful response, so the user can type a follow-up message
* without manually deleting the previous prompt).
*/
/** Clear the user input textarea. */
clearInput(): void {
this._textarea.value = '';
}
/**
* Render the current session's messages into the scrollable history
* area. User messages are plain text; assistant messages are rendered
* as markdown via marked. Auto-scrolls to the bottom so the most
* recent message is visible.
* Bind the prompt to a specific OpenCode session id. Events arriving via
* `applyEvent` for a different session are dropped. Pass null to clear.
*/
setSessionId(sessionId: string | null): void {
this._sessionId = sessionId;
if (sessionId) {
// Fresh turn: drop any in-progress stream DOM from a previous session.
this._resetStreamPointers();
}
}
/**
* Render the current session's static messages (loaded once from
* /session-messages when the panel opens). Subsequent assistant turns
* during the same session are appended live via applyEvent().
*/
setMessages(messages: OpenCodeMessage[]): void {
this._historyEl.textContent = '';
@@ -242,74 +388,501 @@ export class OpenCodeInlinePrompt extends Widget {
if (msg.role === 'user') {
wrap.textContent = msg.content;
} else {
// Assistant: render the markdown, then for every fenced code
// block (<pre>) attach a small toolbar with Copy / Insert /
// Replace buttons that act on THAT block's code (not the whole
// message). Messages without code fences get no toolbar.
const rendered = document.createElement('div');
rendered.className = 'opencode-msg-content';
rendered.innerHTML = marked.parse(msg.content) as string;
const codeBlocks = rendered.querySelectorAll('pre');
codeBlocks.forEach(pre => {
const code = pre.querySelector('code');
if (!code) {
return;
}
// Snapshot the code text before we mutate the DOM so the
// buttons act on the exact code inside this block (no
// surrounding ```fences```, no toolbar label text).
const codeText = code.textContent ?? '';
const wrap = document.createElement('div');
wrap.className = 'opencode-code-block';
const toolbar = document.createElement('div');
toolbar.className = 'opencode-code-toolbar';
const mkBtn = (
label: string,
title: string,
onClick: () => void
) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'opencode-code-btn';
b.textContent = label;
b.title = title;
b.addEventListener('click', onClick);
return b;
};
toolbar.appendChild(
mkBtn('📋 复制', '复制代码', () => {
void this._copyToClipboard(codeText);
})
);
toolbar.appendChild(
mkBtn('↪ 插入', '在光标位置插入', () => {
this._insertAtCursor(codeText);
})
);
toolbar.appendChild(
mkBtn('⟳ 替换', '替换单元格内容', () => {
this._replaceCell(codeText);
})
);
wrap.appendChild(toolbar);
// Move the original <pre> into the wrapper.
pre.parentNode!.insertBefore(wrap, pre);
wrap.appendChild(pre);
});
wrap.appendChild(rendered);
this._renderAssistantMarkdown(wrap, msg.content);
}
this._historyEl.appendChild(wrap);
}
this._historyEl.scrollTop = this._historyEl.scrollHeight;
this._scrollToBottom();
}
/**
* Copy the given text to the clipboard. Prefers navigator.clipboard
* (available in secure contexts incl. JupyterLab). Falls back to a
* hidden textarea + execCommand for older / non-secure contexts.
* Apply one OpenCode SSE event to the streaming UI. Mirrors
* demo.html's handleGlobalEvent / processSessionEvent dispatch.
*/
applyEvent(event: OpenCodeEvent): void {
const type = event.type || (event.payload && event.payload.type) || '';
const props =
event.properties ||
(event.payload && event.payload.properties) ||
{};
const sessionID =
props.sessionID ||
props.sessionId ||
(event.syncEvent && event.syncEvent.aggregateID) ||
(event.payload &&
event.payload.syncEvent &&
event.payload.syncEvent.aggregateID);
// Drop events for other sessions (defensive; matches demo.html
// handleGlobalEvent). Only drop when the event has an explicit
// sessionID — events without one (e.g. system events) are still
// processed and may be dropped by _isSystemEvent below.
if (sessionID && this._sessionId && sessionID !== this._sessionId) {
return;
}
// System / workspace / pty / etc. events are not displayed.
if (this._isSystemEvent(type)) {
return;
}
// For text/reasoning/tool/idle/permission/question we expect to match.
// If none of the branches below fire, log a warning so the missing
// event type can be diagnosed in DevTools.
const handled = this._dispatchContentEvent(event, type, props);
if (!handled) {
console.warn(
'opencode_bridge: unrecognized SSE event type (dropped):',
type,
event
);
}
}
private _dispatchContentEvent(
event: OpenCodeEvent,
type: string,
props: { [k: string]: any }
): boolean {
if (type === 'permission.asked') {
const permId = String(props.id || props.permissionID || '');
this._createPermissionBlock(
permId,
String(
props.description || props.title || '系统请求执行权限(命令行/文件修改)'
)
);
return true;
}
if (type === 'question.asked') {
const qId = String(props.id || props.questionID || '');
this._createQuestionBlock(
qId,
String(props.question || props.title || 'Agent 正在等待确认…')
);
return true;
}
if (
type === 'message.part.delta' &&
props.field === 'text' &&
typeof props.delta === 'string'
) {
this._appendToAssistantText(props.delta);
return true;
}
if (
(type === 'message.part.delta' && props.field === 'reasoning') ||
type === 'session.next.reasoning.delta'
) {
this._appendToBlock(
'reasoning',
'🧠 思考与推理过程',
String(props.delta || props.text || '')
);
return true;
}
if (type === 'session.next.text.delta' && typeof props.delta === 'string') {
this._appendToAssistantText(props.delta);
return true;
}
if (type === 'session.next.reasoning.started') {
this._getOrCreateBlock('reasoning', '🧠 思考与推理过程');
return true;
}
if (
type === 'session.next.tool.input.started' ||
type === 'session.next.tool.called'
) {
const toolName = String(props.name || props.tool || '未知工具');
this._getOrCreateBlock('tool', `🛠️ 调用工具: ${toolName}`);
return true;
}
if (type === 'session.next.tool.input.delta') {
this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
return true;
}
if (type === 'session.next.tool.progress') {
this._appendToBlock(
'tool',
'🛠️ 工具执行中…',
`\n[Progress]: ${String(props.message || '')}`
);
return true;
}
if (type === 'session.next.tool.success') {
this._appendToBlock(
'tool',
'🛠️ 工具执行完毕',
`\n[执行成功结果]: ${JSON.stringify(props.result || {}, null, 2)}`
);
return true;
}
if (type === 'session.next.tool.failed') {
this._appendToBlock(
'tool',
'🛠️ 工具执行失败',
`\n[执行失败异常]: ${String(props.error || 'Unknown')}`
);
return true;
}
if (this._isIdleEvent(event, type, props)) {
this._resetStreamPointers();
this._options.onStreamEnd?.();
return true;
}
return false;
}
/**
* Detect "agent is now idle" across the multiple shapes OpenCode has
* emitted across versions: top-level `session.idle`, top-level
* `session.status` with `status.type === 'idle'`, and the same shapes
* nested under `payload`.
*/
private _isIdleEvent(
event: OpenCodeEvent,
topType: string,
topProps: { [k: string]: any }
): boolean {
const check = (t: string, p: { [k: string]: any }): boolean => {
if (t === 'session.idle') {
return true;
}
if (t === 'session.status' && p && p.status && p.status.type === 'idle') {
return true;
}
return false;
};
if (check(topType, topProps)) {
return true;
}
const payload = event.payload;
if (payload) {
return check(
payload.type || topType,
payload.properties || topProps
);
}
return false;
}
// ---------- Private streaming helpers ----------
private _isSystemEvent(type: string): boolean {
return (
type.startsWith('server.') ||
type.startsWith('workspace.') ||
type.startsWith('worktree.') ||
type.startsWith('lsp.') ||
type.startsWith('mcp.') ||
type.startsWith('pty.') ||
type.startsWith('installation.') ||
type === 'global.disposed' ||
// Session-level control events are not part of the assistant reply
// and would be confusing as separate lines in the history.
type === 'session.next.agent.switched' ||
type === 'session.next.model.switched' ||
type === 'file.edited'
);
}
private _appendToAssistantText(text: string): void {
if (!this._currentAssistantEl) {
this._currentAssistantEl = this._createMessageElement('assistant', '');
}
// Accumulate the raw markdown source, then re-render the whole
// message through marked.parse. This way the user sees properly
// formatted output (code blocks as <pre><code>, headings, lists)
// in real-time as text deltas arrive — NOT the raw ```fence```
// source that an append-only .textContent would show.
this._currentAssistantText += text;
this._renderAssistantMarkdown(
this._currentAssistantEl,
this._currentAssistantText
);
this._scrollToBottom();
}
private _getOrCreateBlock(key: string, title: string): BlockEntry {
let entry = this._activeBlocks[key];
if (entry) {
return entry;
}
const details = document.createElement('details');
details.className = `opencode-msg-block opencode-msg-block-${key}`;
details.open = true;
const summary = document.createElement('summary');
summary.textContent = title;
const content = document.createElement('div');
content.className = 'opencode-msg-block-content';
details.appendChild(summary);
details.appendChild(content);
this._historyEl.appendChild(details);
entry = { details, content };
this._activeBlocks[key] = entry;
this._scrollToBottom();
return entry;
}
private _appendToBlock(
key: string,
defaultTitle: string,
text: string
): void {
const block = this._getOrCreateBlock(key, defaultTitle);
block.content.textContent = (block.content.textContent || '') + text;
this._scrollToBottom();
}
private _createPermissionBlock(permId: string, description: string): void {
if (!permId) {
return;
}
const domId = `opencode-perm-${permId}`;
if (this._historyEl.querySelector('#' + domId)) {
return;
}
const details = document.createElement('details');
details.id = domId;
details.className =
'opencode-msg-block opencode-msg-block-interaction';
details.open = true;
const summary = document.createElement('summary');
summary.textContent = '🔐 权限请求等待处理';
const content = document.createElement('div');
content.className = 'opencode-msg-block-content opencode-perm-body';
const desc = document.createElement('div');
desc.className = 'opencode-perm-desc';
desc.textContent = `请求内容:${description}`;
content.appendChild(desc);
const btnGroup = document.createElement('div');
btnGroup.className = 'opencode-perm-buttons';
const mkBtn = (
label: string,
cls: string,
resp: 'once' | 'always' | 'reject'
): HTMLButtonElement => {
const b = document.createElement('button');
b.type = 'button';
b.className = cls;
b.textContent = label;
b.addEventListener('click', () => {
this._handlePermissionResponse(permId, resp, btnGroup, details);
});
return b;
};
btnGroup.appendChild(
mkBtn('允许一次 (Once)', 'opencode-perm-btn-allow', 'once')
);
btnGroup.appendChild(
mkBtn('总是允许 (Always)', 'opencode-perm-btn-allow', 'always')
);
btnGroup.appendChild(
mkBtn('拒绝 (Reject)', 'opencode-perm-btn-reject', 'reject')
);
content.appendChild(btnGroup);
details.appendChild(summary);
details.appendChild(content);
this._historyEl.appendChild(details);
this._scrollToBottom();
}
private async _handlePermissionResponse(
permId: string,
response: 'once' | 'always' | 'reject',
btnGroup: HTMLElement,
details: HTMLElement
): Promise<void> {
if (!this._options.onPermissionReply) {
btnGroup.textContent = '(未配置 onPermissionReply 回调)';
return;
}
btnGroup.textContent = '正在提交…';
try {
const ok = await this._options.onPermissionReply(permId, response);
const labels: Record<string, string> = {
once: '✓ 已授权 (仅一次)',
always: '✓ 已授权 (总是允许)',
reject: '✕ 已拒绝操作'
};
const color = response === 'reject' ? '#ef4444' : '#10b981';
btnGroup.innerHTML = `<span style="color: ${color}; font-weight: bold;">${
labels[response]
}${ok ? '' : ' (后端失败)'}</span>`;
} catch (e) {
btnGroup.innerHTML = `<span style="color: #ef4444; font-size: 11px;">提交失败: ${(e as Error).message}</span>`;
}
}
private _createQuestionBlock(questionId: string, questionText: string): void {
if (!questionId) {
return;
}
const domId = `opencode-q-${questionId}`;
if (this._historyEl.querySelector('#' + domId)) {
return;
}
const details = document.createElement('details');
details.id = domId;
details.className =
'opencode-msg-block opencode-msg-block-interaction';
details.open = true;
const summary = document.createElement('summary');
summary.textContent = '❓ Agent 提问等待回复';
const content = document.createElement('div');
content.className = 'opencode-msg-block-content opencode-q-body';
const desc = document.createElement('div');
desc.className = 'opencode-q-desc';
desc.textContent = `问题:${questionText}`;
content.appendChild(desc);
const inputRow = document.createElement('div');
inputRow.className = 'opencode-q-input-row';
const input = document.createElement('input');
input.type = 'text';
input.className = 'opencode-q-input';
input.placeholder = '请输入你的回答…';
const submit = document.createElement('button');
submit.type = 'button';
submit.className = 'opencode-q-submit';
submit.textContent = '提交回答';
submit.addEventListener('click', () => {
void this._handleQuestionResponse(
questionId,
input,
inputRow,
details
);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit.click();
}
});
inputRow.appendChild(input);
inputRow.appendChild(submit);
content.appendChild(inputRow);
details.appendChild(summary);
details.appendChild(content);
this._historyEl.appendChild(details);
this._scrollToBottom();
input.focus();
}
private async _handleQuestionResponse(
questionId: string,
input: HTMLInputElement,
inputRow: HTMLElement,
_details: HTMLElement
): Promise<void> {
const answer = input.value.trim();
if (!answer) {
return;
}
if (!this._options.onQuestionReply) {
inputRow.innerHTML = '<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
return;
}
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
try {
const ok = await this._options.onQuestionReply(questionId, answer);
const color = ok ? '#10b981' : '#ef4444';
const text = ok ? `✓ 已回答:${answer}` : `提交失败 (后端 ok=false)`;
inputRow.innerHTML = `<span style="color: ${color}; font-weight: bold;">${text}</span>`;
} catch (e) {
inputRow.innerHTML = `<span style="color: #ef4444;">提交失败: ${(e as Error).message}</span>`;
}
}
private _resetStreamPointers(): void {
this._currentAssistantEl = null;
this._currentAssistantText = '';
this._activeBlocks = {};
}
// ---------- Static-message rendering (used by setMessages + streaming) ----------
private _renderAssistantMarkdown(wrap: HTMLElement, content: string): void {
// Idempotent: clear existing content first so this can be called
// repeatedly with the same `wrap` element (used by streaming —
// every text delta re-renders the whole message).
wrap.textContent = '';
const rendered = document.createElement('div');
rendered.className = 'opencode-msg-content';
rendered.innerHTML = marked.parse(content) as string;
const codeBlocks = rendered.querySelectorAll('pre');
codeBlocks.forEach(pre => {
const code = pre.querySelector('code');
if (!code) {
return;
}
const codeText = code.textContent ?? '';
const blockWrap = document.createElement('div');
blockWrap.className = 'opencode-code-block';
const toolbar = document.createElement('div');
toolbar.className = 'opencode-code-toolbar';
const mkBtn = (
label: string,
title: string,
onClick: () => void
): HTMLButtonElement => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'opencode-code-btn';
b.textContent = label;
b.title = title;
b.addEventListener('click', onClick);
return b;
};
toolbar.appendChild(
mkBtn('📋 复制', '复制代码', () => {
void this._copyToClipboard(codeText);
})
);
toolbar.appendChild(
mkBtn('↪ 插入', '在光标位置插入', () => {
this._insertAtCursor(codeText);
})
);
toolbar.appendChild(
mkBtn('⟳ 替换', '替换单元格内容', () => {
this._replaceCell(codeText);
})
);
blockWrap.appendChild(toolbar);
pre.parentNode!.insertBefore(blockWrap, pre);
blockWrap.appendChild(pre);
});
wrap.appendChild(rendered);
}
private _createMessageElement(role: string, text: string): HTMLDivElement {
const div = document.createElement('div');
div.className = `opencode-msg opencode-msg-${role}`;
div.textContent = text;
this._historyEl.appendChild(div);
this._scrollToBottom();
return div;
}
private _scrollToBottom(): void {
this._historyEl.scrollTop = this._historyEl.scrollHeight;
}
// ---------- Cell-edit actions (Copy / Insert / Replace) ----------
private async _copyToClipboard(text: string): Promise<void> {
try {
if (
@@ -335,11 +908,6 @@ export class OpenCodeInlinePrompt extends Widget {
}
}
/**
* Insert text at the current editor cursor position. If the cell has
* no editor (e.g. a non-Code cell) or the cursor can't be read, falls
* back to appending at the end of the cell.
*/
private _insertAtCursor(text: string): void {
const cell = this._cell;
if (!cell || !cell.model || !cell.model.sharedModel) {
@@ -367,7 +935,6 @@ export class OpenCodeInlinePrompt extends Widget {
offset += col;
}
} catch {
// Fall back to append on any error reading the cursor.
offset = source.length;
}
const newSource = source.slice(0, offset) + text + source.slice(offset);
@@ -375,7 +942,6 @@ export class OpenCodeInlinePrompt extends Widget {
Notification.info('已插入到光标位置');
}
/** Replace the entire cell source with the given text. */
private _replaceCell(text: string): void {
const cell = this._cell;
if (!cell || !cell.model || !cell.model.sharedModel) {
+32 -11
View File
@@ -1,12 +1,13 @@
/**
* 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 {
@@ -33,21 +34,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
+129
View File
@@ -210,3 +210,132 @@
opacity: 0.4;
cursor: default;
}
/* ------------------------------------------------------------------
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);
}