Initial commit: opencode_bridge JupyterLab extension (Slices 1-3.5)

A JupyterLab extension that bridges the cell UI to a local OpenCode Serve
process. The extension is a dual package: a Python server extension
exposed under /opencode-bridge/*, plus a TypeScript frontend that
registers per-cell toolbars.

Backend (Python, tornado)
- Slice 1: config + auth + OpenCode HTTP client (tornado.httpclient,
  no aiohttp). 4 settings in schema/plugin.json (url, user, password,
  request timeout).
- Slice 2: handlers for /hello, /health, /providers, /edit.
- Slice 2.1 (correction): SessionManager with 1 notebook = 1 session
  mapping, async-safe via per-path locks, 404 recovery via invalidate().
  Two new endpoints: GET /sessions, DELETE /session?notebook=<path>.
- 32 pytest tests pass.

Frontend (TypeScript, JupyterLab 4.6)
- src/types.ts: CellContext, OpenCodeRequest/Response, OpenCodeSettings.
- src/context/cell_context.ts: extract CellContext from a CodeCell +
  its parent NotebookPanel, structured error collection.
- src/api/opencode_client.ts: callOpenCodeEdit, callOpenCodeProviders.
- src/components/opencode_cell_footer.ts: OpenCodeCellFooter Widget
  implementing ICellFooter with 3 buttons (optimize / fix / edit),
  resolved via this.parent instanceof CodeCell. NOT cellToolbar
  (does not exist in JL 4.6) and NOT Widget.findParent (removed in
  @lumino/widgets 2.x).
- src/components/opencode_cell_factory.ts: Cell.ContentFactory
  subclass returning the OpenCodeCellFooter.
- src/components/opencode_installer.ts: installOpenCodeEverywhere
  patches every notebook (existing + new) to use the custom factory.
- src/index.ts: registers the factory, loads settings, fetches
  /providers on activation and logs the list to the console.
- 23 jest tests pass (mocked JupyterLab boundary, pnpm path safe).

Settings
- 6 fields: 3 auth (url/user/password) + 1 timeout + 2 model selection
  (provider/model). Provider list is fetched at startup from
  /opencode-bridge/providers and printed to the browser console so
  users can copy values into Settings Editor.

Docs
- design.md: 6 sections covering architecture, UI flow, API contract,
  TS skeletons, session management (v0.2.1 correction), and
  provider/model selection (v0.2.2 addition).
- CLAUDE.md: agent guidance for working in this repo.
- TODO.md: remaining work for Slices 4-7 + v0.4+ backlog.

CI
- Gitea release workflow at .github/workflows/build.yml.
- Bark notification helper (non-fatal on failure).

Generated artefacts ignored: opencode_bridge/labextension/, _version.py,
*.tsbuildinfo, junit.xml, test.ipynb scratch notebook.
This commit is contained in:
tao.chen
2026-07-22 19:07:58 +08:00
commit c919c95842
61 changed files with 26642 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
"""Async HTTP client for the local OpenCode server."""
import json
from typing import Any, Optional
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import HTTPHeaders
from .config import OpenCodeConfig
class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails."""
class OpenCodeClient:
def __init__(
self,
config: OpenCodeConfig,
http_client: Optional[AsyncHTTPClient] = None,
) -> None:
self._config = config
self._http_client = http_client or AsyncHTTPClient()
async def health(self) -> dict[str, Any]:
return await self._request("GET", "/global/health")
async def list_providers(self) -> list[dict[str, Any]]:
return await self._request("GET", "/config/providers")
async def create_session(self, title: str) -> dict[str, Any]:
return await self._request("POST", "/session", {"title": title})
async def send_message_sync(
self,
session_id: str,
parts: list[dict[str, Any]],
provider_id: Optional[str] = None,
model_id: Optional[str] = None,
system: Optional[str] = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"parts": parts}
if provider_id is not None and model_id is not None:
body["model"] = {"providerID": provider_id, "modelID": model_id}
if system is not None:
body["system"] = system
return await self._request(
"POST",
"/session/%s/message" % session_id,
body,
)
async def abort(self, session_id: str) -> bool:
result = await self._request("POST", "/session/%s/abort" % session_id)
return result is not None
async def delete_session(self, session_id: str) -> bool:
result = await self._request("DELETE", "/session/%s" % session_id)
return result is not None
@property
def endpoint(self) -> str:
return self._config.url
async def _request(
self,
method: str,
path: str,
body: Optional[dict[str, Any]] = None,
) -> Optional[Any]:
url = self._config.url + path
headers = HTTPHeaders(
{
"Content-Type": "application/json",
"Accept": "application/json",
}
)
request_kwargs: dict[str, Any] = {
"method": method,
"headers": headers,
"request_timeout": self._config.request_timeout_seconds,
}
if body is not None:
request_kwargs["body"] = json.dumps(body).encode("utf-8")
request = HTTPRequest(url, **request_kwargs)
fetch_kwargs: dict[str, str] = {}
if self._config.auth is not None:
fetch_kwargs["auth_username"] = self._config.auth[0]
fetch_kwargs["auth_password"] = self._config.auth[1]
response = await self._http_client.fetch(request, **fetch_kwargs)
if 200 <= response.code < 300:
if not response.body:
return True
return json.loads(response.body.decode("utf-8"))
if response.code == 404:
return None
response_body = response.body.decode("utf-8") if response.body else ""
raise OpenCodeError(
"OpenCode request %s %s failed with status %s: %s"
% (method, url, response.code, response_body)
)