feat: async + SSE message flow with interactive permission/question UI

Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-27 17:38:53 +08:00
committed by tao.chen
co-authored by Claude
parent a43174e5bc
commit b7089519fd
12 changed files with 2532 additions and 243 deletions
+582 -33
View File
@@ -109,7 +109,10 @@ beforeAll(() => {
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 +122,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 {
@@ -395,58 +412,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 +830,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 };
}
+113 -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();
@@ -119,14 +132,61 @@ export class OpenCodeCellActions extends Widget {
},
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 +204,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 +228,7 @@ export class OpenCodeCellActions extends Widget {
return;
}
const request: OpenCodeRequest = {
const request = {
prompt: text,
context: this._context,
providerId,
@@ -180,41 +238,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);
}
}
+569 -89
View File
@@ -1,26 +1,59 @@
/**
* 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';
export interface IOpenCodeInlinePromptOptions {
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
onCancel: () => void;
disabled: boolean;
providers: OpenCodeProvidersResponse | null;
/**
* 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 +98,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,12 +115,28 @@ 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);
@@ -125,8 +180,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 +220,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);
@@ -219,20 +272,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 +302,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 +822,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 +849,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 +856,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