feat: Copy / Insert / Replace buttons on assistant messages
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ce59502e97
commit
cb3576c83a
@@ -52,6 +52,15 @@ jest.mock('marked', () => ({
|
|||||||
marked: { parse: (md: string) => `<mock>${md}</mock>` }
|
marked: { parse: (md: string) => `<mock>${md}</mock>` }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// a real notebook server (and to keep the refresh-history logic decoupled
|
||||||
// from the network). The cell_actions tests focus on widget behavior.
|
// 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)();
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||||
(cell as any).model = model;
|
(cell as any).model = model;
|
||||||
(cell as any).node = document.createElement('div');
|
(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 notebook: any = { widgets: [] as CodeCell[] };
|
||||||
const panel: any = {
|
const panel: any = {
|
||||||
@@ -417,4 +431,97 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
expect(asstMsg.innerHTML).toContain('<mock>');
|
expect(asstMsg.innerHTML).toContain('<mock>');
|
||||||
expect(asstMsg.innerHTML).toContain('# Here you go');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* (the AI reply is rendered as markdown inside this history area).
|
* (the AI reply is rendered as markdown inside this history area).
|
||||||
*/
|
*/
|
||||||
import { CodeCell } from '@jupyterlab/cells';
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ function pickDefaultModelId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeInlinePrompt extends Widget {
|
export class OpenCodeInlinePrompt extends Widget {
|
||||||
|
private _cell: CodeCell;
|
||||||
private _textarea: HTMLTextAreaElement;
|
private _textarea: HTMLTextAreaElement;
|
||||||
private _sendBtn: HTMLButtonElement;
|
private _sendBtn: HTMLButtonElement;
|
||||||
private _cancelBtn: HTMLButtonElement;
|
private _cancelBtn: HTMLButtonElement;
|
||||||
@@ -75,10 +77,11 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
private _historyEl: HTMLDivElement;
|
private _historyEl: HTMLDivElement;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
_cell: CodeCell,
|
cell: CodeCell,
|
||||||
options: IOpenCodeInlinePromptOptions
|
options: IOpenCodeInlinePromptOptions
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
this._cell = cell;
|
||||||
this.addClass('opencode-inline-prompt');
|
this.addClass('opencode-inline-prompt');
|
||||||
|
|
||||||
const flat = flattenProviders(options.providers);
|
const flat = flattenProviders(options.providers);
|
||||||
@@ -222,10 +225,124 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
wrap.textContent = msg.content;
|
wrap.textContent = msg.content;
|
||||||
} else {
|
} 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.appendChild(wrap);
|
||||||
}
|
}
|
||||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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('已替换单元格内容');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,28 @@
|
|||||||
color: var(--jp-ui-font-color0, #000);
|
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 {
|
.opencode-inline-prompt .opencode-inline-history pre {
|
||||||
background: var(--jp-layout-color1, #fff);
|
background: var(--jp-layout-color1, #fff);
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user