From cb3576c83a687a1ecca77b28fd0003d1c19c0576 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:13:28 +0800 Subject: [PATCH] feat: Copy / Insert / Replace buttons on assistant messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each assistant message in the inline prompt history now renders a small toolbar with three action buttons: 📋 复制 — copy the rendered assistant content (the markdown source, not the rendered HTML) to the clipboard via navigator.clipboard.writeText, with a fallback to a hidden textarea + execCommand for non-secure contexts. ↪ 插入 — insert the content at the current editor cursor. Uses cell.editor.getCursorPosition() + the cell source to compute the offset, then sharedModel.setSource to splice the text in. Falls back to appending if the cursor can't be read. ⟳ 替换 — replace the entire cell source with the assistant content via sharedModel.setSource. User messages and the textarea/send/cancel UI are unchanged; the toolbar only appears on rendered assistant (markdown) messages. Implementation: - OpenCodeInlinePrompt now stores the cell (private _cell) so the button handlers can act on it. - New private methods: _copyToClipboard, _insertAtCursor, _replaceCell. Each shows a Notification on success/failure. - Added import { Notification } from '@jupyterlab/apputils' (the prompt file didn't import it before; cell_actions does, so the test mock already provides the static methods). - style/base.css: .opencode-msg-toolbar (flex row) and .opencode-msg-btn (small bordered button) styles. - Test fake cell now has an editor stub returning cursor (0,0) so the Insert test produces a deterministic offset. jsdom lacks navigator.clipboard; the test stubs it globally. Tests: - jest 33/33 (was 29; +4 new: toolbar renders 3 buttons only on assistant, Replace sets source, Insert splices at offset 0, Copy calls clipboard.writeText with the content). - pytest 37/37 (no server changes). Co-Authored-By: Claude Fable 5 --- src/__tests__/opencode_cell_actions.spec.ts | 107 +++++++++++++++++ src/components/opencode_inline_prompt.ts | 121 +++++++++++++++++++- style/base.css | 22 ++++ 3 files changed, 248 insertions(+), 2 deletions(-) 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;