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.
1287 lines
44 KiB
TypeScript
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('已替换单元格内容');
|
|
}
|
|
}
|