Files
notebook-ai-extension/src/components/opencode_inline_prompt.ts
T
tao.chen 310d50759a fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.

Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.

Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.

Backend
-------
- _build_request_body: now takes only the prompt, returns
  parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
  tag wrapping.

Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
  cellId / source / previousCode / error / cellIndex / totalCells
  / language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
  with extractNotebookPathFromCell(). _context field renamed to
  _notebookPath. NotebookPanel / extractCellContext / context/
  cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
  deleted.

New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.

Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
  3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
  green.
2026-07-28 18:02:21 +08:00

1287 lines
44 KiB
TypeScript

/**
* 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. 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 {
OpenCodeEvent,
OpenCodeMessage,
OpenCodeProvidersResponse,
OpenCodeSessionMeta
} from '../types';
import {
loadModelSelection,
saveModelSelection
} from './model_selection';
export interface IOpenCodeInlinePromptOptions {
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
onCancel: () => void;
disabled: boolean;
providers: OpenCodeProvidersResponse | null;
/**
* Notebook path used to key the persisted (provider, model) selection
* in localStorage. If omitted (or the stored selection is no longer
* valid in the current `providers` payload), the prompt falls back
* to the default selection (first provider / `default[pid]` model).
*/
notebookPath?: string;
/**
* 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;
// ----- session management (multi-session UI) -----
/** List ALL OpenCode sessions (for the session-picker dropdown).
* Returns the raw array; the prompt picks the active one out. */
onListSessions?: () => Promise<OpenCodeSessionMeta[]>;
/** Create a brand-new session, bind it to the current notebook, and
* return its id (which becomes the new active session). */
onCreateSession?: () => Promise<string>;
/** Switch the active session. Returns true on success. */
onSwitchSession?: (sessionId: string) => Promise<boolean>;
/** Reload the message history for the currently-active session.
* Called after a create / switch so the panel reflects the new
* conversation's scrollback. */
onReloadHistory?: () => Promise<void>;
}
interface FlatProvider {
id: string;
name?: string;
models: { [modelID: string]: unknown };
}
function flattenProviders(
providers: OpenCodeProvidersResponse | null
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
const out: FlatProvider[] = [];
if (!providers || !providers.providers) {
return { providers: out, defaultMap: (providers && providers.default) || {} };
}
for (const p of providers.providers) {
if (!p.models || typeof p.models !== 'object') {
continue;
}
out.push({ id: p.id, name: p.name, models: p.models });
}
return { providers: out, defaultMap: providers.default || {} };
}
function modelKeys(models: { [modelID: string]: unknown }): string[] {
return Object.keys(models);
}
function pickDefaultModelId(
providerId: string,
models: { [modelID: string]: unknown },
defaultMap: { [pid: string]: string }
): string | undefined {
const keys = modelKeys(models);
if (keys.length === 0) {
return undefined;
}
const fromDefault = defaultMap[providerId];
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
return fromDefault;
}
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;
private _insertCellBtn: HTMLButtonElement;
private _providerSelect: HTMLSelectElement | null = null;
private _modelSelect: HTMLSelectElement | null = null;
private _providerModels: FlatProvider[];
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;
// Multi-session UI: the dropdown and its row (null when the host
// did not wire up the session callbacks).
private _sessionRow: HTMLDivElement | null = null;
private _sessionSelect: HTMLSelectElement | 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);
this._providerModels = flat.providers;
this._defaultMap = flat.defaultMap;
// Resolve the initial selection: prefer a previously-saved
// (providerId, modelId) IF it's still valid in the current
// providers payload; otherwise fall back to the default
// (first provider / `default[pid]` model).
const stored = this._resolveStoredSelection(options.notebookPath);
if (this._providerModels.length > 0) {
this._providerSelect = document.createElement('select');
this._providerSelect.className = 'opencode-provider-select';
for (const p of this._providerModels) {
const o = document.createElement('option');
o.value = p.id;
o.textContent = p.name ? `${p.id} (${p.name})` : p.id;
this._providerSelect.appendChild(o);
}
this._providerSelect.value = stored
? stored.providerId
: this._providerModels[0].id;
this._providerSelect.addEventListener('change', () => {
this._rebuildModelSelect(this._providerSelect!.value);
this._persistSelection();
});
}
if (this._providerModels.length > 0) {
this._modelSelect = document.createElement('select');
this._modelSelect.className = 'opencode-model-select';
this._rebuildModelSelect(
this._providerSelect?.value,
stored ? stored.modelId : undefined
);
this._modelSelect.addEventListener('change', () => {
this._persistSelection();
});
}
this._textarea = document.createElement('textarea');
this._textarea.placeholder = '向 AI 描述你想要的修改…';
this._textarea.rows = 3;
this._sendBtn = document.createElement('button');
this._sendBtn.className = 'opencode-btn-send';
this._sendBtn.textContent = '发送';
this._sendBtn.disabled = options.disabled;
this._sendBtn.addEventListener('click', () => {
const text = this._textarea.value;
if (!text.trim()) {
return;
}
const providerId = this._providerSelect?.value;
const modelId = this._modelSelect?.value || undefined;
options.onSubmit(text, providerId, modelId);
});
this._textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
this._sendBtn.click();
}
});
this._cancelBtn = document.createElement('button');
this._cancelBtn.className = 'opencode-btn-cancel';
this._cancelBtn.textContent = '取消';
this._cancelBtn.addEventListener('click', () => {
options.onCancel();
});
// "📋 插入单元格内容" button: appends the current cell's source
// (wrapped in a markdown code fence) to the textarea. Lets the
// user explicitly opt-in to including the cell content in the
// LLM prompt — the cell is no longer auto-injected.
this._insertCellBtn = document.createElement('button');
this._insertCellBtn.type = 'button';
this._insertCellBtn.className = 'opencode-btn-insert-cell';
this._insertCellBtn.textContent = '📋 插入单元格内容';
this._insertCellBtn.title = '把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
this._insertCellBtn.addEventListener('click', () => {
this._insertCellSource();
});
const actions = document.createElement('div');
actions.className = 'opencode-inline-actions';
actions.appendChild(this._insertCellBtn);
actions.appendChild(this._sendBtn);
actions.appendChild(this._cancelBtn);
// Session selector (top of the panel, above provider/model).
// Only rendered when the host has provided the multi-session
// callbacks. The active session id is the option currently
// selected; switching the dropdown calls onSwitchSession, picking
// "+ 新建会话" calls onCreateSession, and the refresh button
// re-fetches the global session list.
if (
this._options.onListSessions &&
this._options.onCreateSession &&
this._options.onSwitchSession
) {
this._sessionRow = document.createElement('div');
this._sessionRow.className = 'opencode-prompt-sessions';
const sLabel = document.createElement('label');
const sSpan = document.createElement('span');
sSpan.textContent = 'Session:';
sLabel.appendChild(sSpan);
this._sessionSelect = document.createElement('select');
this._sessionSelect.className = 'opencode-session-select';
// Single placeholder until the list loads.
this._sessionSelect.appendChild(
this._mkSessionOption('', '加载中…', false)
);
this._sessionSelect.disabled = true;
this._sessionSelect.addEventListener('change', () => {
const v = this._sessionSelect!.value;
if (v === '__new__') {
void this._handleCreateSession();
} else if (v && v !== this._sessionId) {
void this._handleSwitchSession(v);
}
});
sLabel.appendChild(this._sessionSelect);
this._sessionRow.appendChild(sLabel);
const newBtn = document.createElement('button');
newBtn.type = 'button';
newBtn.className = 'opencode-session-btn-new';
newBtn.textContent = '+ 新建';
newBtn.title = '在 OpenCode 上创建新会话并绑定到当前 notebook';
newBtn.addEventListener('click', () => {
void this._handleCreateSession();
});
this._sessionRow.appendChild(newBtn);
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'opencode-session-btn-refresh';
refreshBtn.textContent = '🔄';
refreshBtn.title = '刷新 OpenCode 全局会话列表';
refreshBtn.addEventListener('click', () => {
void this._refreshSessionList();
});
this._sessionRow.appendChild(refreshBtn);
this.node.appendChild(this._sessionRow);
}
if (this._providerSelect || this._modelSelect) {
const row = document.createElement('div');
row.className = 'opencode-prompt-providers';
if (this._providerSelect) {
const pLabel = document.createElement('label');
const pSpan = document.createElement('span');
pSpan.textContent = 'Provider:';
pLabel.appendChild(pSpan);
pLabel.appendChild(this._providerSelect);
row.appendChild(pLabel);
}
if (this._modelSelect) {
const mLabel = document.createElement('label');
const mSpan = document.createElement('span');
mSpan.textContent = 'Model:';
mLabel.appendChild(mSpan);
mLabel.appendChild(this._modelSelect);
row.appendChild(mLabel);
}
this.node.appendChild(row);
}
// 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);
this.node.appendChild(this._textarea);
this.node.appendChild(actions);
}
private _rebuildModelSelect(
providerId: string | undefined,
preferredModelId?: string
): void {
if (!this._modelSelect) {
return;
}
while (this._modelSelect.firstChild) {
this._modelSelect.removeChild(this._modelSelect.firstChild);
}
if (!providerId) {
this._modelSelect.disabled = true;
return;
}
const provider = this._providerModels.find(p => p.id === providerId);
if (!provider) {
this._modelSelect.disabled = true;
return;
}
const keys = modelKeys(provider.models);
if (keys.length === 0) {
this._modelSelect.disabled = true;
return;
}
for (const k of keys) {
const o = document.createElement('option');
o.value = k;
o.textContent = k;
this._modelSelect.appendChild(o);
}
this._modelSelect.disabled = false;
// Honor a preferred model (e.g. from a stored selection), but only
// if it actually exists in this provider's model list. Otherwise
// fall back to the default.
if (
preferredModelId &&
Object.prototype.hasOwnProperty.call(provider.models, preferredModelId)
) {
this._modelSelect.value = preferredModelId;
} else {
this._modelSelect.value =
pickDefaultModelId(providerId, provider.models, this._defaultMap) ??
keys[0];
}
}
/**
* Read the stored (providerId, modelId) for this notebook and
* validate it against the current providers payload. Returns the
* stored selection only when BOTH the provider and the model still
* exist; otherwise returns null and the caller uses the default.
*/
private _resolveStoredSelection(
notebookPath: string | undefined
): { providerId: string; modelId: string } | null {
if (!notebookPath) {
return null;
}
const sel = loadModelSelection(notebookPath);
if (!sel) {
return null;
}
const provider = this._providerModels.find(p => p.id === sel.providerId);
if (!provider) {
// The provider was removed since the user last picked.
return null;
}
if (!Object.prototype.hasOwnProperty.call(provider.models, sel.modelId)) {
// The model was removed (or renamed) under a still-valid provider.
return null;
}
return sel;
}
// ---------- session selector (multi-session UI) ----------
/**
* Repopulate the session dropdown with the given list. The currently
* active session (this._sessionId, set by setSessionId or by
* _handleSwitchSession) is marked selected.
*/
private _populateSessionSelect(sessions: OpenCodeSessionMeta[]): void {
if (!this._sessionSelect) {
return;
}
// Clear existing options (drop the loading placeholder).
while (this._sessionSelect.firstChild) {
this._sessionSelect.removeChild(this._sessionSelect.firstChild);
}
for (const s of sessions) {
const label = s.title
? `${s.title} (${s.id.slice(0, 8)}…)`
: `${s.id.slice(0, 8)}…`;
this._sessionSelect.appendChild(
this._mkSessionOption(s.id, label, s.id === this._sessionId)
);
}
// Always-available "create new" sentinel.
this._sessionSelect.appendChild(
this._mkSessionOption('__new__', '+ 新建会话…', false)
);
// Reflect the current active id (or "" if none).
this._sessionSelect.value = this._sessionId || '';
if (!this._sessionId) {
// No active session; show a hint via the first option being empty.
this._sessionSelect.value = '';
}
this._sessionSelect.disabled = false;
}
private _mkSessionOption(
value: string,
label: string,
selected: boolean
): HTMLOptionElement {
const o = document.createElement('option');
o.value = value;
o.textContent = label;
if (selected) {
o.selected = true;
}
return o;
}
/**
* Fetch the global session list from the host and repopulate the
* dropdown. Called on prompt show, on "刷新" click, and after
* every create / switch. Errors are non-fatal — the dropdown just
* keeps whatever it had (or stays empty).
*/
async _refreshSessionList(): Promise<void> {
if (!this._options.onListSessions) {
return;
}
try {
const sessions = await this._options.onListSessions();
this._populateSessionSelect(sessions);
} catch (e) {
console.warn('opencode_bridge: failed to list sessions', e);
}
}
/**
* "新建" button or dropdown's __new__ entry. Creates a session,
* binds it, sets as active, refreshes the dropdown, and reloads the
* message history for the new (empty) session.
*/
private async _handleCreateSession(): Promise<void> {
if (!this._options.onCreateSession) {
return;
}
if (this._sessionSelect) {
this._sessionSelect.disabled = true;
}
try {
const newId = await this._options.onCreateSession();
// setSessionId() handles _sessionId + dropdown + reset pointers
// in one call; avoids duplicate work in this handler.
this.setSessionId(newId);
this._historyEl.textContent = '';
// Re-fetch the global list so the new id is in the dropdown
// and marked active.
await this._refreshSessionList();
// Reload history for the new session (empty list, but this
// also re-syncs the prompt's view of "current state").
if (this._options.onReloadHistory) {
try {
await this._options.onReloadHistory();
} catch (e) {
console.warn('opencode_bridge: reload history failed', e);
}
}
} catch (e) {
Notification.error('创建会话失败: ' + (e as Error).message);
if (this._sessionSelect) {
this._sessionSelect.disabled = false;
}
}
}
/**
* User picked a different session from the dropdown. Asks the host
* to bind + set active; on success, refreshes history.
*/
private async _handleSwitchSession(sessionId: string): Promise<void> {
if (!this._options.onSwitchSession) {
return;
}
if (this._sessionSelect) {
this._sessionSelect.disabled = true;
}
try {
const ok = await this._options.onSwitchSession(sessionId);
if (ok) {
// setSessionId() handles _sessionId + dropdown + reset
// pointers in one call; avoids duplicate work.
this.setSessionId(sessionId);
this._historyEl.textContent = '';
if (this._options.onReloadHistory) {
try {
await this._options.onReloadHistory();
} catch (e) {
console.warn('opencode_bridge: reload history failed', e);
}
}
} else {
// Roll back the select to the previous value.
if (this._sessionSelect) {
this._sessionSelect.value = this._sessionId || '';
}
Notification.error('切换会话失败');
}
} catch (e) {
Notification.error('切换会话失败: ' + (e as Error).message);
if (this._sessionSelect) {
this._sessionSelect.value = this._sessionId || '';
}
} finally {
if (this._sessionSelect) {
this._sessionSelect.disabled = false;
}
}
}
/**
* Persist the current (providerSelect.value, modelSelect.value) for
* this notebook. No-op if the prompt has no notebookPath, no
* selects, or the values are empty.
*/
private _persistSelection(): void {
const notebookPath = this._options.notebookPath;
if (!notebookPath || !this._providerSelect || !this._modelSelect) {
return;
}
const providerId = this._providerSelect.value;
const modelId = this._modelSelect.value;
if (!providerId || !modelId) {
return;
}
saveModelSelection(notebookPath, { providerId, modelId });
}
setDisabled(disabled: boolean): void {
this._sendBtn.disabled = disabled;
if (this._providerSelect) {
this._providerSelect.disabled = disabled;
}
if (this._modelSelect) {
this._modelSelect.disabled = disabled;
}
if (this._sessionSelect) {
// Don't disable the select while streaming — the user can still
// switch to a different session to inspect it. The cell action
// closes the SSE on switch.
this._sessionSelect.disabled = false;
}
}
/** Clear the user input textarea. */
clearInput(): void {
this._textarea.value = '';
}
/**
* "📋 插入单元格内容" button: append the current cell's source as a
* markdown code block to the textarea. The LLM only sees what the
* user explicitly chose to include — the cell source is NEVER
* auto-injected (v0.2.x change).
*/
private _insertCellSource(): void {
if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
Notification.info('当前 cell 没有内容');
return;
}
const source = this._cell.model.sharedModel.getSource() as string;
if (!source || !source.trim()) {
Notification.info('当前 cell 为空');
return;
}
// Use the cell's language as the fence info string when it
// looks like a real language identifier; otherwise use a plain
// fence so markdown stays portable.
const langRaw =
(this._cell.model as any).sharedModel && (this._cell.model as any).type
? ((this._cell.model as any).type as string)
: 'python';
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
const before = this._textarea.value;
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const insertion = (needsLeadingNewline ? '\n' : '') + block;
this._textarea.value = before + insertion;
// Move cursor to end so the user can continue typing.
this._textarea.focus();
const end = this._textarea.value.length;
this._textarea.setSelectionRange(end, end);
this._scrollToBottom();
}
/**
* 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 (this._sessionSelect) {
// Reflect the new active id in the dropdown without rebuilding it.
if (sessionId) {
const has = Array.from(this._sessionSelect.options).some(
o => o.value === sessionId
);
if (has) {
this._sessionSelect.value = sessionId;
}
} else {
this._sessionSelect.value = '';
}
}
if (sessionId) {
// Fresh turn: drop any in-progress stream DOM from a previous session.
this._resetStreamPointers();
}
}
/**
* First-time wiring after the prompt is constructed. Loads the global
* session list into the dropdown. Call from the host (cell action)
* right after constructing the widget.
*/
initialize(): void {
void this._refreshSessionList();
}
/**
* 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 = '';
for (const msg of messages) {
const wrap = document.createElement('div');
wrap.className = `opencode-msg opencode-msg-${msg.role}`;
if (msg.role === 'user') {
wrap.textContent = msg.content;
} else {
this._renderAssistantMarkdown(wrap, msg.content);
}
this._historyEl.appendChild(wrap);
}
this._scrollToBottom();
}
/**
* 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);
// System / workspace / pty / etc. events are not displayed.
if (this._isSystemEvent(type)) {
return;
}
// Cross-session filtering: only strict for permission/question
// events (the user must NOT be able to reply to a permission
// prompt that belongs to a different session). Content events
// (text delta, reasoning, tool, idle) pass through regardless of
// sessionID match — the user is looking at the active session's
// panel, and we don't want to drop their reply because of a
// sessionID extraction edge case. The server already forwards
// ALL events; the SSE handler routes by URL ?session= param.
if (
(type === 'permission.asked' || type === 'question.asked') &&
sessionID &&
this._sessionId &&
sessionID !== this._sessionId
) {
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', '');
}
// During streaming, append the raw delta to the assistant element's
// textContent. We do NOT run marked.parse on every delta: the
// partial source (e.g. an opening ```py fence with no closing
// fence yet) is structurally different from the complete message
// and the toolbar post-processing creates visible flicker —
// <pre>s appear and disappear as the renderer re-classifies the
// incomplete markdown between deltas. The user gets stable
// real-time text here, and the full markdown render happens once
// at the end of the turn (see _resetStreamPointers) so the result
// is identical to what setMessages would produce from the stored
// history.
this._currentAssistantText += text;
this._currentAssistantEl.textContent = 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 {
// Finalize the in-progress assistant message by running the
// markdown render ONCE on the accumulated raw text. This is the
// moment where the streaming path converges to the same DOM
// structure that setMessages would produce from the stored
// history (i.e. identical to "reopen" behavior). Without this
// final pass the user would see raw ```fence``` source
// permanently until they close+reopen the panel.
if (this._currentAssistantEl && this._currentAssistantText) {
this._renderAssistantMarkdown(
this._currentAssistantEl,
this._currentAssistantText
);
}
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 (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === 'function'
) {
await navigator.clipboard.writeText(text);
Notification.info('已复制到剪贴板');
return;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
this.node.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
Notification.info('已复制到剪贴板');
} catch (e) {
Notification.error('复制失败: ' + (e as Error).message);
}
}
private _insertAtCursor(text: string): void {
const cell = this._cell;
if (!cell || !cell.model || !cell.model.sharedModel) {
return;
}
const sharedModel = cell.model.sharedModel;
const source = sharedModel.getSource() as string;
let offset = source.length;
try {
const editor = (cell as any).editor;
const pos = editor && editor.getCursorPosition
? editor.getCursorPosition()
: null;
if (pos) {
const lines = source.split('\n');
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
const col = Math.max(
0,
Math.min(pos.column, (lines[line] ?? '').length)
);
offset = 0;
for (let i = 0; i < line; i++) {
offset += lines[i].length + 1;
}
offset += col;
}
} catch {
offset = source.length;
}
const newSource = source.slice(0, offset) + text + source.slice(offset);
sharedModel.setSource(newSource);
Notification.info('已插入到光标位置');
}
private _replaceCell(text: string): void {
const cell = this._cell;
if (!cell || !cell.model || !cell.model.sharedModel) {
return;
}
cell.model.sharedModel.setSource(text);
Notification.info('已替换单元格内容');
}
}