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
+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) {