Files
notebook-ai-extension/opencode_bridge/opencode_client.py
T
tao.chenandClaude Fable 5 ce59502e97 feat: inline prompt history area — scrollable session messages
The inline prompt now shows the current session's full message
history (user + assistant), scrollable, replacing the single-output
display from the previous commit. The user can scroll back through
prior exchanges in the same notebook's OpenCode session.

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:45:03 +08:00

152 lines
5.3 KiB
Python

"""Async HTTP client for the local OpenCode server."""
import atexit
import json
from typing import Any, Optional
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import HTTPHeaders
from .config import OpenCodeConfig
class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails."""
# Ensure the tornado AsyncHTTPClient singleton is closed at interpreter
# shutdown so the IOLoop can finish and the process can exit cleanly
# (otherwise Ctrl+C on `jupyter lab` hangs after "received signal 2,
# stopping" because the singleton keeps persistent connections alive).
def _close_opencode_http() -> None:
try:
AsyncHTTPClient().close()
except Exception:
# Never block interpreter shutdown on close errors.
pass
atexit.register(_close_opencode_http)
class OpenCodeClient:
def __init__(
self,
config: OpenCodeConfig,
http_client: Optional[AsyncHTTPClient] = None,
) -> None:
self._config = config
# Use a dedicated client (not the singleton) so close() can
# deterministically release its connections without affecting
# other components. The module-level atexit still closes the
# singleton as a safety net.
self._http_client = http_client or AsyncHTTPClient(force_instance=True)
self._owns_http_client = http_client is None
def close(self) -> None:
"""Release the underlying HTTP client's connections. Idempotent."""
if getattr(self, "_http_client", None) is None:
return
try:
if self._owns_http_client:
self._http_client.close()
except Exception:
pass
self._http_client = None # type: ignore[assignment]
self._owns_http_client = False
async def health(self) -> dict[str, Any]:
return await self._request("GET", "/global/health")
async def list_providers(self) -> list[dict[str, Any]]:
return await self._request("GET", "/config/providers")
async def create_session(self, title: str) -> dict[str, Any]:
return await self._request("POST", "/session", {"title": title})
async def send_message_sync(
self,
session_id: str,
parts: list[dict[str, Any]],
provider_id: Optional[str] = None,
model_id: Optional[str] = None,
system: Optional[str] = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"parts": parts}
if provider_id is not None and model_id is not None:
body["model"] = {"providerID": provider_id, "modelID": model_id}
if system is not None:
body["system"] = system
return await self._request(
"POST",
"/session/%s/message" % session_id,
body,
)
async def abort(self, session_id: str) -> bool:
result = await self._request("POST", "/session/%s/abort" % session_id)
return result is not None
async def delete_session(self, session_id: str) -> bool:
result = await self._request("DELETE", "/session/%s" % session_id)
return result is not None
async def list_session_messages(self, session_id: str) -> list[dict[str, Any]]:
"""List all messages in the given OpenCode session.
Returns the raw OpenCode response: a list of
`{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
The server extension is responsible for projecting this into a
frontend-friendly `{role, content}[]` shape.
"""
return await self._request("GET", "/session/%s/message" % session_id)
@property
def endpoint(self) -> str:
return self._config.url
async def _request(
self,
method: str,
path: str,
body: Optional[dict[str, Any]] = None,
) -> Optional[Any]:
url = self._config.url + path
headers = HTTPHeaders(
{
"Content-Type": "application/json",
"Accept": "application/json",
}
)
request_kwargs: dict[str, Any] = {
"method": method,
"headers": headers,
"request_timeout": self._config.request_timeout_seconds,
}
if body is not None:
request_kwargs["body"] = json.dumps(body).encode("utf-8")
# HTTP Basic Auth must be set on the HTTPRequest itself; passing
# auth_* as **kwargs to fetch() when the first arg is already an
# HTTPRequest is rejected by tornado ("kwargs can't be used if
# request is an HTTPRequest object").
if self._config.auth is not None:
request_kwargs["auth_username"] = self._config.auth[0]
request_kwargs["auth_password"] = self._config.auth[1]
request = HTTPRequest(url, **request_kwargs)
response = await self._http_client.fetch(request)
if 200 <= response.code < 300:
if not response.body:
return True
return json.loads(response.body.decode("utf-8"))
if response.code == 404:
return None
response_body = response.body.decode("utf-8") if response.body else ""
raise OpenCodeError(
"OpenCode request %s %s failed with status %s: %s"
% (method, url, response.code, response_body)
)