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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user