diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts
index 5090bf8..ab7cf3c 100644
--- a/src/__tests__/opencode_cell_actions.spec.ts
+++ b/src/__tests__/opencode_cell_actions.spec.ts
@@ -52,6 +52,15 @@ jest.mock('marked', () => ({
marked: { parse: (md: string) => `${md}` }
}));
+// jsdom doesn't implement navigator.clipboard; stub it so the copy
+// button's happy path can be exercised in tests.
+beforeAll(() => {
+ Object.defineProperty(globalThis.navigator, 'clipboard', {
+ value: { writeText: jest.fn().mockResolvedValue(undefined) },
+ configurable: true
+ });
+});
+
// Mock the network-touching API module so jsdom tests don't try to hit
// a real notebook server (and to keep the refresh-history logic decoupled
// from the network). The cell_actions tests focus on widget behavior.
@@ -101,6 +110,11 @@ function makeFakeCell(
const cell = new (CodeCell as unknown as new () => CodeCell)();
(cell as any).model = model;
(cell as any).node = document.createElement('div');
+ // Minimal editor stub: cursor always at (line 0, column 0) so the
+ // "insert at cursor" tests produce a deterministic offset.
+ (cell as any).editor = {
+ getCursorPosition: () => ({ line: 0, column: 0 })
+ };
const notebook: any = { widgets: [] as CodeCell[] };
const panel: any = {
@@ -417,4 +431,97 @@ describe('OpenCodeInlinePrompt', () => {
expect(asstMsg.innerHTML).toContain('');
expect(asstMsg.innerHTML).toContain('# Here you go');
});
+
+ it('renders a Copy / Insert / Replace toolbar on assistant messages', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setMessages([
+ { role: 'user', content: 'help' },
+ { role: 'assistant', content: '```py\nprint(1)\n```' }
+ ]);
+
+ const asst = prompt.node.querySelector(
+ '.opencode-msg-assistant'
+ ) as HTMLElement;
+ const toolbar = asst.querySelector(
+ '.opencode-msg-toolbar'
+ ) as HTMLElement;
+ expect(toolbar).not.toBeNull();
+ const buttons = toolbar.querySelectorAll('button');
+ expect(buttons.length).toBe(3);
+ expect(buttons[0].textContent).toContain('复制');
+ expect(buttons[1].textContent).toContain('插入');
+ expect(buttons[2].textContent).toContain('替换');
+ // User messages must NOT get a toolbar.
+ const user = prompt.node.querySelector('.opencode-msg-user');
+ expect(user!.querySelector('.opencode-msg-toolbar')).toBeNull();
+ });
+
+ it('Replace button overwrites the cell source with the assistant content', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setMessages([
+ { role: 'assistant', content: 'print("replaced")' }
+ ]);
+ const replaceBtn = prompt.node.querySelector(
+ '.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(3)'
+ ) as HTMLButtonElement;
+ replaceBtn.click();
+
+ const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
+ expect(setSource).toHaveBeenCalledTimes(1);
+ expect(setSource).toHaveBeenCalledWith('print("replaced")');
+ });
+
+ it('Insert button splices the content at the editor cursor (offset 0)', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setMessages([
+ { role: 'assistant', content: 'inserted()\n' }
+ ]);
+ const insertBtn = prompt.node.querySelector(
+ '.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(2)'
+ ) as HTMLButtonElement;
+ insertBtn.click();
+
+ const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
+ expect(setSource).toHaveBeenCalledTimes(1);
+ // cursor at (0,0) with source "x = 1" -> content is inserted at offset 0
+ expect(setSource).toHaveBeenCalledWith('inserted()\nx = 1');
+ });
+
+ it('Copy button writes the assistant content to the clipboard', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setMessages([
+ { role: 'assistant', content: 'copy me' }
+ ]);
+ const copyBtn = prompt.node.querySelector(
+ '.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(1)'
+ ) as HTMLButtonElement;
+ copyBtn.click();
+
+ const writeText = (navigator.clipboard.writeText as unknown as jest.Mock);
+ expect(writeText).toHaveBeenCalledWith('copy me');
+ });
});
diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts
index ab4a4d1..4fe3ad3 100644
--- a/src/components/opencode_inline_prompt.ts
+++ b/src/components/opencode_inline_prompt.ts
@@ -10,6 +10,7 @@
* (the AI reply is rendered as markdown inside this history area).
*/
import { CodeCell } from '@jupyterlab/cells';
+import { Notification } from '@jupyterlab/apputils';
import { Widget } from '@lumino/widgets';
import { marked } from 'marked';
@@ -65,6 +66,7 @@ function pickDefaultModelId(
}
export class OpenCodeInlinePrompt extends Widget {
+ private _cell: CodeCell;
private _textarea: HTMLTextAreaElement;
private _sendBtn: HTMLButtonElement;
private _cancelBtn: HTMLButtonElement;
@@ -75,10 +77,11 @@ export class OpenCodeInlinePrompt extends Widget {
private _historyEl: HTMLDivElement;
constructor(
- _cell: CodeCell,
+ cell: CodeCell,
options: IOpenCodeInlinePromptOptions
) {
super();
+ this._cell = cell;
this.addClass('opencode-inline-prompt');
const flat = flattenProviders(options.providers);
@@ -222,10 +225,124 @@ export class OpenCodeInlinePrompt extends Widget {
if (msg.role === 'user') {
wrap.textContent = msg.content;
} else {
- wrap.innerHTML = marked.parse(msg.content) as string;
+ // Assistant: small action toolbar (copy / insert at cursor /
+ // replace cell) + the rendered markdown content below.
+ const toolbar = document.createElement('div');
+ toolbar.className = 'opencode-msg-toolbar';
+ const content = msg.content;
+ const mkBtn = (label: string, title: string, onClick: () => void) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'opencode-msg-btn';
+ b.textContent = label;
+ b.title = title;
+ b.addEventListener('click', onClick);
+ return b;
+ };
+ toolbar.appendChild(
+ mkBtn('📋 复制', '复制内容到剪贴板', () => {
+ void this._copyToClipboard(content);
+ })
+ );
+ toolbar.appendChild(
+ mkBtn('↪ 插入', '在光标位置插入内容', () => {
+ this._insertAtCursor(content);
+ })
+ );
+ toolbar.appendChild(
+ mkBtn('⟳ 替换', '替换整个单元格内容', () => {
+ this._replaceCell(content);
+ })
+ );
+ wrap.appendChild(toolbar);
+
+ const rendered = document.createElement('div');
+ rendered.className = 'opencode-msg-content';
+ rendered.innerHTML = marked.parse(content) as string;
+ wrap.appendChild(rendered);
}
this._historyEl.appendChild(wrap);
}
this._historyEl.scrollTop = this._historyEl.scrollHeight;
}
+
+ /**
+ * 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.
+ */
+ private async _copyToClipboard(text: string): Promise {
+ 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);
+ }
+ }
+
+ /**
+ * 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) {
+ 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 {
+ // Fall back to append on any error reading the cursor.
+ offset = source.length;
+ }
+ const newSource = source.slice(0, offset) + text + source.slice(offset);
+ sharedModel.setSource(newSource);
+ 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) {
+ return;
+ }
+ cell.model.sharedModel.setSource(text);
+ Notification.info('已替换单元格内容');
+ }
}
diff --git a/style/base.css b/style/base.css
index b742521..30ba745 100644
--- a/style/base.css
+++ b/style/base.css
@@ -111,6 +111,28 @@
color: var(--jp-ui-font-color0, #000);
}
+.opencode-inline-prompt .opencode-msg-toolbar {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 4px;
+}
+
+.opencode-inline-prompt .opencode-msg-btn {
+ border: 1px solid var(--jp-border-color2, #ccc);
+ background: var(--jp-layout-color1, #fff);
+ padding: 1px 6px;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: var(--jp-ui-font-size0, 11px);
+ line-height: 1.4;
+ color: var(--jp-ui-font-color1, #333);
+}
+
+.opencode-inline-prompt .opencode-msg-btn:hover {
+ background: var(--jp-layout-color2, #f7f7f7);
+ border-color: var(--jp-border-color1, #999);
+}
+
.opencode-inline-prompt .opencode-inline-history pre {
background: var(--jp-layout-color1, #fff);
padding: 6px 8px;