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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1d7f5da9d4
commit
ce59502e97
@@ -91,6 +91,16 @@ class OpenCodeClient:
|
||||
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
|
||||
|
||||
@@ -249,6 +249,58 @@ class SessionReleaseHandler(APIHandler):
|
||||
}))
|
||||
|
||||
|
||||
class SessionMessagesHandler(APIHandler):
|
||||
"""List the current session's messages for a notebook (scrollable history).
|
||||
|
||||
Query param: notebook=<notebook path, URL-encoded>
|
||||
Returns: { messages: [{ role: "user"|"assistant", content: string }] }
|
||||
If no session exists for the notebook yet, returns { messages: [] }
|
||||
(does NOT create a session just to report emptiness).
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
async def get(self):
|
||||
notebook_path = self.get_query_argument("notebook", "")
|
||||
if not notebook_path:
|
||||
self.set_status(400)
|
||||
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
|
||||
return
|
||||
sm = get_session_manager(self)
|
||||
sid = sm.peek(notebook_path)
|
||||
if sid is None:
|
||||
self.finish(json.dumps({"messages": []}))
|
||||
return
|
||||
try:
|
||||
client = make_client(self)
|
||||
raw = await client.list_session_messages(sid)
|
||||
except OpenCodeError as e:
|
||||
if "404" in str(e) or "not found" in str(e).lower():
|
||||
sm.invalidate(notebook_path)
|
||||
log.warning("invalidated dead session for %s", notebook_path)
|
||||
log.exception("list session messages failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
return
|
||||
except Exception as e:
|
||||
log.exception("list session messages failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
return
|
||||
|
||||
# Project OpenCode's {info, parts}[] into a frontend-friendly
|
||||
# {role, content}[] by joining the text parts.
|
||||
messages = []
|
||||
for m in raw or []:
|
||||
info = m.get("info") or {}
|
||||
role = info.get("role") or "assistant"
|
||||
parts = m.get("parts") or []
|
||||
content = "\n".join(
|
||||
p.get("text", "") for p in parts if p.get("type") == "text"
|
||||
).strip()
|
||||
messages.append({"role": role, "content": content})
|
||||
self.finish(json.dumps({"messages": messages}))
|
||||
|
||||
|
||||
def setup_route_handlers(web_app):
|
||||
host_pattern = ".*$"
|
||||
base_url = web_app.settings["base_url"]
|
||||
@@ -260,6 +312,7 @@ def setup_route_handlers(web_app):
|
||||
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
||||
]
|
||||
|
||||
web_app.add_handlers(host_pattern, handlers)
|
||||
|
||||
@@ -83,6 +83,13 @@ class SessionManager:
|
||||
def has_session(self, notebook_path: str) -> bool:
|
||||
return notebook_path in self._sessions
|
||||
|
||||
def peek(self, notebook_path: str) -> Optional[str]:
|
||||
"""Return the cached sessionID for the notebook, or None if no
|
||||
session has been created yet. Does NOT create one (unlike
|
||||
get_or_create) — used by the history endpoint to avoid spawning
|
||||
a session just to report that there is none."""
|
||||
return self._sessions.get(notebook_path)
|
||||
|
||||
def list_sessions(self) -> list[dict]:
|
||||
return [
|
||||
{"notebookPath": path, "sessionId": sid}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import tornado.httpclient
|
||||
|
||||
|
||||
class FakeOpenCodeClient:
|
||||
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
|
||||
@@ -13,6 +16,18 @@ class FakeOpenCodeClient:
|
||||
"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 = [
|
||||
{
|
||||
"info": {"role": "user", "id": "m1"},
|
||||
"parts": [{"type": "text", "text": "fix the bug"}],
|
||||
},
|
||||
{
|
||||
"info": {"role": "assistant", "id": "m2"},
|
||||
"parts": [{"type": "text", "text": "```python\nx = 1\n```"}],
|
||||
},
|
||||
]
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
@@ -38,6 +53,10 @@ class FakeOpenCodeClient:
|
||||
self.calls.append(("delete_session", session_id))
|
||||
return True
|
||||
|
||||
async def list_session_messages(self, session_id):
|
||||
self.calls.append(("list_session_messages", session_id))
|
||||
return self.messages_response
|
||||
|
||||
|
||||
class FakeSessionManager:
|
||||
"""Drop-in replacement for SessionManager with recording."""
|
||||
@@ -50,6 +69,12 @@ class FakeSessionManager:
|
||||
self.calls.append(("get_or_create", notebook_path))
|
||||
return self._session_id
|
||||
|
||||
def peek(self, notebook_path: str):
|
||||
# Mirror SessionManager.peek: return the session id without
|
||||
# creating one. None means "no session yet" (used by the no-session
|
||||
# test to short-circuit the history route).
|
||||
return self._session_id or None
|
||||
|
||||
async def release(self, notebook_path: str) -> bool:
|
||||
self.calls.append(("release", notebook_path))
|
||||
return True
|
||||
@@ -177,6 +202,71 @@ async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||
assert paths == {"foo.ipynb", "bar.ipynb"}
|
||||
|
||||
|
||||
async def test_session_messages_handler_returns_empty_when_no_session(
|
||||
monkeypatch, jp_fetch
|
||||
) -> None:
|
||||
# No session registered for this notebook -> handler returns
|
||||
# {"messages": []} WITHOUT calling OpenCodeClient (peek short-circuits).
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
fake_sm = FakeSessionManager(session_id="")
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||
)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "session-messages",
|
||||
method="GET",
|
||||
params={"notebook": "fresh.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {"messages": []}
|
||||
# No OpenCode call was made.
|
||||
assert all(c[0] != "list_session_messages" for c in fake.calls)
|
||||
|
||||
|
||||
async def test_session_messages_handler_projects_opencode_messages(
|
||||
monkeypatch, jp_fetch
|
||||
) -> None:
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||
)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "session-messages",
|
||||
method="GET",
|
||||
params={"notebook": "test.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {
|
||||
"messages": [
|
||||
{"role": "user", "content": "fix the bug"},
|
||||
{"role": "assistant", "content": "```python\nx = 1\n```"},
|
||||
]
|
||||
}
|
||||
# The OpenCode client was called with the session id from the manager.
|
||||
assert ("list_session_messages", "fake-session-123") in fake.calls
|
||||
|
||||
|
||||
async def test_session_messages_handler_requires_notebook(
|
||||
monkeypatch, jp_fetch
|
||||
) -> None:
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
# jp_fetch raises HTTPClientError on 4xx; assert the handler 400s
|
||||
# (the body would contain "missing 'notebook' query parameter").
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch("opencode-bridge", "session-messages", method="GET")
|
||||
assert exc_info.value.code == 400
|
||||
|
||||
|
||||
async def test_session_release_handler(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
@@ -52,6 +52,15 @@ jest.mock('marked', () => ({
|
||||
marked: { parse: (md: string) => `<mock>${md}</mock>` }
|
||||
}));
|
||||
|
||||
// Mock the network-touching API module so jsdom tests don't try to hit
|
||||
// a real notebook server (and to keep the refresh-history logic decoupled
|
||||
// from the network). The cell_actions tests focus on widget behavior.
|
||||
jest.mock('../api/opencode_client', () => ({
|
||||
callOpenCodeEdit: jest.fn(),
|
||||
callOpenCodeProviders: jest.fn(),
|
||||
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] })
|
||||
}));
|
||||
|
||||
import {
|
||||
OpenCodeCellActions,
|
||||
setOpenCodeProviders,
|
||||
@@ -330,17 +339,21 @@ describe('OpenCodeCellActions', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('on a successful response, does NOT replace the cell source (renders to inline output instead)', () => {
|
||||
it('on a successful response, does NOT replace the cell source and triggers a history refresh', () => {
|
||||
setOpenCodeProviders(null);
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
// Click once to create the inline prompt (so setOutput has a target).
|
||||
// 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();
|
||||
|
||||
// Simulate a successful response.
|
||||
// The history refresh on show is async; spy on _refreshHistory to
|
||||
// confirm it gets called (without depending on the network).
|
||||
const refreshSpy = jest.spyOn(actions as any, '_refreshHistory');
|
||||
|
||||
(actions as any)._handleResponse({
|
||||
ok: true,
|
||||
markdown: '# AI says hi\n\n```python\nprint("hi")\n```',
|
||||
@@ -350,11 +363,8 @@ describe('OpenCodeCellActions', () => {
|
||||
|
||||
// The cell source must remain unchanged.
|
||||
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
|
||||
// The inline output area is now visible with the rendered markdown.
|
||||
const output = cell.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-inline-output'
|
||||
) as HTMLElement;
|
||||
expect(output.style.display).toBe('block');
|
||||
// A history refresh was triggered (to pick up the new assistant msg).
|
||||
expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,11 +378,12 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
providers: null
|
||||
});
|
||||
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
||||
// 3 buttons: 发送, 取消, and the output area close (✕).
|
||||
expect(prompt.node.querySelectorAll('button').length).toBe(3);
|
||||
// 2 buttons: 发送 and 取消 (no more output-area close button; the
|
||||
// scrollable history area is the display).
|
||||
expect(prompt.node.querySelectorAll('button').length).toBe(2);
|
||||
});
|
||||
|
||||
it('hides the output area by default; setOutput reveals it with rendered markdown', () => {
|
||||
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
@@ -380,22 +391,30 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
const output = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-inline-output'
|
||||
const history = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-inline-history'
|
||||
) as HTMLElement;
|
||||
expect(output).not.toBeNull();
|
||||
expect(output.style.display).toBe('none');
|
||||
expect(history).not.toBeNull();
|
||||
// Initially empty (no messages yet).
|
||||
expect(history.children.length).toBe(0);
|
||||
|
||||
prompt.setOutput('# Hello\n\n```python\nx = 1\n```');
|
||||
expect(output.style.display).toBe('block');
|
||||
const content = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-inline-output-content'
|
||||
) as HTMLElement;
|
||||
// marked is mocked to wrap the input in <mock>...</mock>.
|
||||
expect(content.innerHTML).toContain('<mock>');
|
||||
expect(content.innerHTML).toContain('# Hello');
|
||||
|
||||
prompt.hideOutput();
|
||||
expect(output.style.display).toBe('none');
|
||||
prompt.setMessages([
|
||||
{ role: 'user', content: 'fix the bug' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '# Here you go\n\n```python\nx = 1\n```'
|
||||
}
|
||||
]);
|
||||
expect(history.children.length).toBe(2);
|
||||
const userMsg = history.querySelector('.opencode-msg-user') as HTMLElement;
|
||||
const asstMsg = history.querySelector('.opencode-msg-assistant') as HTMLElement;
|
||||
expect(userMsg).not.toBeNull();
|
||||
// User messages are plain text (not rendered as HTML).
|
||||
expect(userMsg.textContent).toBe('fix the bug');
|
||||
expect(userMsg.innerHTML).not.toContain('<');
|
||||
// Assistant messages are rendered as markdown via marked (mocked).
|
||||
expect(asstMsg).not.toBeNull();
|
||||
expect(asstMsg.innerHTML).toContain('<mock>');
|
||||
expect(asstMsg.innerHTML).toContain('# Here you go');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { URLExt } from '@jupyterlab/coreutils';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
|
||||
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse } from '../types';
|
||||
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
|
||||
@@ -54,6 +54,37 @@ export async function callOpenCodeEdit(
|
||||
return parsed as OpenCodeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call GET /opencode-bridge/session-messages?notebook=<path>. Returns the
|
||||
* current session's messages (projected to {role, content}[]) for the
|
||||
* given notebook, or an empty list if no session exists yet.
|
||||
*/
|
||||
export async function callOpenCodeSessionMessages(
|
||||
notebookPath: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeMessagesResponse> {
|
||||
const url =
|
||||
URLExt.join(serverSettings.baseUrl, 'opencode-bridge', 'session-messages') +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath);
|
||||
|
||||
const init: RequestInit = { method: 'GET' };
|
||||
let response: Response;
|
||||
try {
|
||||
response = await ServerConnection.makeRequest(url, init, serverSettings);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`network error calling opencode-bridge/session-messages: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`opencode-bridge/session-messages failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as OpenCodeMessagesResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call GET /opencode-bridge/providers. Returns the parsed JSON response.
|
||||
* Throws on network error or non-2xx status.
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { NotebookPanel } from '@jupyterlab/notebook';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import { callOpenCodeEdit } from '../api/opencode_client';
|
||||
import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
|
||||
import { extractCellContext } from '../context/cell_context';
|
||||
import type {
|
||||
CellContext,
|
||||
@@ -123,6 +123,31 @@ export class OpenCodeCellActions extends Widget {
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
this._prompt = prompt;
|
||||
|
||||
// Fetch the current session's message history and render it into
|
||||
// the prompt's scrollable history area. Empty list (no session yet)
|
||||
// is a normal no-op render.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||
if (!_serverSettings || !this._prompt) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await callOpenCodeSessionMessages(
|
||||
notebookPath,
|
||||
_serverSettings
|
||||
);
|
||||
this._prompt.setMessages(resp.messages);
|
||||
} catch (e) {
|
||||
// Non-fatal: the history is a convenience. The user can still
|
||||
// send new prompts; just the scrollback won't update.
|
||||
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||
}
|
||||
}
|
||||
|
||||
private _hidePrompt(): void {
|
||||
@@ -172,11 +197,12 @@ export class OpenCodeCellActions extends Widget {
|
||||
private _handleResponse(resp: OpenCodeResponse): void {
|
||||
this._status = 'idle';
|
||||
if (resp.ok) {
|
||||
// Display the AI response in the inline prompt's output area as
|
||||
// rendered markdown. The cell source is NOT replaced — the user
|
||||
// decides what to do with the output (close it, or resend).
|
||||
if (this._prompt) {
|
||||
this._prompt.setOutput(resp.markdown);
|
||||
// Refetch the (now-updated) session history and render the new
|
||||
// assistant message as the last item in the scrollable history.
|
||||
// The cell source is NOT replaced.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
Notification.info(
|
||||
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||
* Renders two <select> boxes (provider + model), a textarea, and send/cancel
|
||||
* buttons. The model select's default for the chosen provider is
|
||||
* `default[providerID]` (from the /config/providers response) when that
|
||||
* modelID is present in the provider's models; otherwise the first model.
|
||||
* 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.
|
||||
* v3-final: no settings — picks come from the OpenCode Serve providers
|
||||
* cache; history comes from /session-messages; cell source is NOT replaced
|
||||
* (the AI reply is rendered as markdown inside this history area).
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import type { OpenCodeProvidersResponse } from '../types';
|
||||
import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
|
||||
|
||||
export interface IOpenCodeInlinePromptOptions {
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||
@@ -70,8 +72,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
private _modelSelect: HTMLSelectElement | null = null;
|
||||
private _providerModels: FlatProvider[];
|
||||
private _defaultMap: { [pid: string]: string };
|
||||
private _outputArea: HTMLDivElement;
|
||||
private _outputContent: HTMLDivElement;
|
||||
private _historyEl: HTMLDivElement;
|
||||
|
||||
constructor(
|
||||
_cell: CodeCell,
|
||||
@@ -154,33 +155,15 @@ 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.
|
||||
this._historyEl = document.createElement('div');
|
||||
this._historyEl.className = 'opencode-inline-history';
|
||||
this.node.appendChild(this._historyEl);
|
||||
|
||||
this.node.appendChild(this._textarea);
|
||||
this.node.appendChild(actions);
|
||||
|
||||
// Output area: hidden by default; revealed by setOutput() with the
|
||||
// markdown-rendered AI response. The cell source is NOT replaced.
|
||||
this._outputArea = document.createElement('div');
|
||||
this._outputArea.className = 'opencode-inline-output';
|
||||
this._outputArea.style.display = 'none';
|
||||
const outputHeader = document.createElement('div');
|
||||
outputHeader.className = 'opencode-inline-output-header';
|
||||
const outputTitle = document.createElement('span');
|
||||
outputTitle.textContent = '结果';
|
||||
const outputCloseBtn = document.createElement('button');
|
||||
outputCloseBtn.className = 'opencode-inline-output-close';
|
||||
outputCloseBtn.type = 'button';
|
||||
outputCloseBtn.textContent = '✕';
|
||||
outputCloseBtn.title = '关闭输出';
|
||||
outputCloseBtn.addEventListener('click', () => {
|
||||
this.hideOutput();
|
||||
});
|
||||
outputHeader.appendChild(outputTitle);
|
||||
outputHeader.appendChild(outputCloseBtn);
|
||||
this._outputContent = document.createElement('div');
|
||||
this._outputContent.className = 'opencode-inline-output-content';
|
||||
this._outputArea.appendChild(outputHeader);
|
||||
this._outputArea.appendChild(this._outputContent);
|
||||
this.node.appendChild(this._outputArea);
|
||||
}
|
||||
|
||||
private _rebuildModelSelect(providerId: string | undefined): void {
|
||||
@@ -226,18 +209,23 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the AI response as markdown and show the output area in place
|
||||
* of replacing the cell source. Safe for local-trusted content from the
|
||||
* user's OpenCode Serve; do not feed untrusted markdown here without
|
||||
* adding a sanitizer (e.g. DOMPurify).
|
||||
* 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.
|
||||
*/
|
||||
setOutput(markdown: string): void {
|
||||
this._outputContent.innerHTML = marked.parse(markdown) as string;
|
||||
this._outputArea.style.display = 'block';
|
||||
}
|
||||
|
||||
hideOutput(): void {
|
||||
this._outputArea.style.display = 'none';
|
||||
this._outputContent.textContent = '';
|
||||
setMessages(messages: OpenCodeMessage[]): void {
|
||||
this._historyEl.textContent = '';
|
||||
for (const msg of messages) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = `opencode-msg opencode-msg-${msg.role}`;
|
||||
if (msg.role === 'user') {
|
||||
wrap.textContent = msg.content;
|
||||
} else {
|
||||
wrap.innerHTML = marked.parse(msg.content) as string;
|
||||
}
|
||||
this._historyEl.appendChild(wrap);
|
||||
}
|
||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-6
@@ -49,13 +49,20 @@ export interface OpenCodeFailure {
|
||||
|
||||
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
||||
|
||||
/** Response from GET /opencode-bridge/session-messages?notebook=... .
|
||||
* Projected from OpenCode's {info, parts}[] by the server into a
|
||||
* frontend-friendly {role, content}[]. */
|
||||
export interface OpenCodeMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeMessagesResponse {
|
||||
messages: OpenCodeMessage[];
|
||||
}
|
||||
|
||||
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
||||
*
|
||||
* Per the OpenCode server docs, `/config/providers` returns
|
||||
* `{ providers: Provider[], default: { [providerID]: modelID } }`. Each
|
||||
* `Provider.models` is a Record keyed by modelID (NOT an array), so we
|
||||
* can reference a model by its string ID (also matching `default[providerID]`).
|
||||
*/
|
||||
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
||||
export interface OpenCodeModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
|
||||
+35
-40
@@ -75,48 +75,43 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Markdown output area: hidden until setOutput() reveals it. */
|
||||
.opencode-inline-prompt .opencode-inline-output {
|
||||
display: none;
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
/* Scrollable history of the current session's messages. The owning
|
||||
cell action populates it via setMessages(). max-height keeps the
|
||||
inline prompt compact; the user scrolls to see older messages. */
|
||||
.opencode-inline-prompt .opencode-inline-history {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
margin: 4px 0;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--jp-border-color1, #c0c0c0);
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color2, #f7f7f7);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
font-weight: 600;
|
||||
color: var(--jp-ui-font-color1, #333);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
color: var(--jp-ui-font-color2, #666);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-close:hover {
|
||||
color: var(--jp-ui-font-color0, #000);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-content {
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-content pre {
|
||||
.opencode-inline-prompt .opencode-msg {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg-user {
|
||||
white-space: pre-wrap;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color3, #eaeaea);
|
||||
color: var(--jp-ui-font-color1, #333);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg-assistant {
|
||||
padding: 4px 6px;
|
||||
color: var(--jp-ui-font-color0, #000);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-history pre {
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
@@ -124,18 +119,18 @@
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-content code {
|
||||
.opencode-inline-prompt .opencode-inline-history code {
|
||||
font-family: var(--jp-code-font-family, monospace);
|
||||
font-size: var(--jp-code-font-size, 13px);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-content p {
|
||||
.opencode-inline-prompt .opencode-inline-history p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-inline-output-content h1,
|
||||
.opencode-inline-prompt .opencode-inline-output-content h2,
|
||||
.opencode-inline-prompt .opencode-inline-output-content h3 {
|
||||
.opencode-inline-prompt .opencode-inline-history h1,
|
||||
.opencode-inline-prompt .opencode-inline-history h2,
|
||||
.opencode-inline-prompt .opencode-inline-history h3 {
|
||||
margin: 6px 0 4px;
|
||||
font-size: var(--jp-ui-font-size2, 14px);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user