fix: move Copy/Insert/Replace toolbar INSIDE each code block

The previous commit put the toolbar at the top of every assistant
message. The intent was actually to put the buttons inside the
fenced code blocks (the parts wrapped in ```python etc.), not above
the whole result section.

Changes:
  - opencode_inline_prompt.ts: removed the message-level toolbar on
    .opencode-msg-assistant. Instead, after marked.parse the assistant
    markdown, for every rendered <pre> we wrap it in
    .opencode-code-block and prepend a small toolbar with the same
    three buttons. Each button acts on the code text of THAT specific
    block (snapshot of <code>.textContent taken before DOM mutation,
    so the toolbar label text never leaks into the copied/inserted/
    replaced value). Messages with no code fences get no toolbar.
  - style/base.css: replaced .opencode-msg-toolbar / .opencode-msg-btn
    with .opencode-code-block / .opencode-code-toolbar /
    .opencode-code-btn. The wrapped <pre> connects visually to the
    toolbar (no top border/radius, no top margin) so each fenced
    block reads as one unit (GitHub-style).
  - Tests: updated the marked mock to produce real <pre><code> for
    fenced code (so the per-block post-processing can find them)
    and <h1>/<p> for headings. The 4 button tests now look for the
    toolbar inside .opencode-code-block and assert the button acts
    on the code block text ('print(1)'), not the whole markdown.
    Added a test that an assistant message with no code fences has
    no toolbar.

Verification: pytest 37/37, jest 34/34, build OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-23 19:19:29 +08:00
co-authored by Claude Fable 5
parent cb3576c83a
commit b5642ec5cd
3 changed files with 178 additions and 65 deletions
+95 -26
View File
@@ -49,7 +49,49 @@ jest.mock('@lumino/widgets', () => {
jest.mock('marked', () => ({
__esModule: true,
marked: { parse: (md: string) => `<mock>${md}</mock>` }
marked: {
// Minimal but realistic mock: produce a real <pre><code> for
// fenced code blocks (so the per-code-block toolbar
// post-processing can find them) and <h1>/<p> for headings/text.
// Not a full markdown renderer — just enough for the prompt tests.
parse: (md: string) => {
let html = '';
let inFence = false;
let fenceLang = '';
let fenceBuf: string[] = [];
const lines = md.split('\n');
for (const line of lines) {
const open = !inFence && /^```(\w*)\s*$/.exec(line);
const close = inFence && /^```\s*$/.test(line);
if (open) {
inFence = true;
fenceLang = open[1] || '';
fenceBuf = [];
continue;
}
if (close) {
html +=
'<pre><code class="language-' +
fenceLang +
'">' +
fenceBuf.join('\n') +
'</code></pre>';
inFence = false;
continue;
}
if (inFence) {
fenceBuf.push(line);
continue;
}
if (/^# /.test(line)) {
html += '<h1>' + line.slice(2) + '</h1>';
} else if (line.trim()) {
html += '<p>' + line + '</p>';
}
}
return html || '<p>' + md + '</p>';
}
}
}));
// jsdom doesn't implement navigator.clipboard; stub it so the copy
@@ -426,13 +468,13 @@ describe('OpenCodeInlinePrompt', () => {
// User messages are plain text (not rendered as HTML).
expect(userMsg.textContent).toBe('fix the bug');
expect(userMsg.innerHTML).not.toContain('<');
// Assistant messages are rendered as markdown via marked (mocked).
// Assistant content: the marked mock produces a real <h1> + <pre>.
expect(asstMsg).not.toBeNull();
expect(asstMsg.innerHTML).toContain('<mock>');
expect(asstMsg.innerHTML).toContain('# Here you go');
expect(asstMsg.innerHTML).toContain('<h1>Here you go</h1>');
expect(asstMsg.innerHTML).toContain('<pre>');
});
it('renders a Copy / Insert / Replace toolbar on assistant messages', () => {
it('renders Copy/Insert/Replace buttons INSIDE each code block (not above the message)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -440,29 +482,36 @@ describe('OpenCodeInlinePrompt', () => {
disabled: false,
providers: null
});
// User message: no toolbar.
prompt.setMessages([{ role: 'user', content: 'help' }]);
expect(
prompt.node.querySelector('.opencode-msg-user .opencode-code-toolbar')
).toBeNull();
// Assistant with a fenced code block: toolbar inside .opencode-code-block.
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;
// The message-level toolbar must NOT exist.
expect(asst.querySelector('.opencode-msg-toolbar')).toBeNull();
// The per-code-block wrapper + toolbar.
const block = asst.querySelector('.opencode-code-block') as HTMLElement;
expect(block).not.toBeNull();
const toolbar = block.querySelector('.opencode-code-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();
// The wrapped <pre> is inside the block, below the toolbar.
expect(block.querySelector('pre')).not.toBeNull();
});
it('Replace button overwrites the cell source with the assistant content', () => {
it('assistant with NO code fences has no toolbar', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -471,19 +520,39 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
prompt.setMessages([
{ role: 'assistant', content: 'print("replaced")' }
{ role: 'assistant', content: 'no code here, just an explanation' }
]);
const asst = prompt.node.querySelector(
'.opencode-msg-assistant'
) as HTMLElement;
expect(asst.querySelector('.opencode-code-toolbar')).toBeNull();
expect(asst.querySelector('.opencode-code-block')).toBeNull();
});
it('Replace button overwrites the cell source with the code block 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: '```py\nprint(1)\n```' }
]);
const replaceBtn = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(3)'
'.opencode-msg-assistant .opencode-code-block .opencode-code-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")');
// The button acts on the code block's text, not the whole markdown
// (no surrounding ```fences```).
expect(setSource).toHaveBeenCalledWith('print(1)');
});
it('Insert button splices the content at the editor cursor (offset 0)', () => {
it('Insert button splices the code block at the editor cursor (offset 0)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -492,20 +561,20 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
prompt.setMessages([
{ role: 'assistant', content: 'inserted()\n' }
{ role: 'assistant', content: '```py\nprint(1)\n```' }
]);
const insertBtn = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(2)'
'.opencode-msg-assistant .opencode-code-block .opencode-code-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');
// cursor at (0,0) with source "x = 1" -> code "print(1)" inserted at 0
expect(setSource).toHaveBeenCalledWith('print(1)x = 1');
});
it('Copy button writes the assistant content to the clipboard', () => {
it('Copy button writes the code block content to the clipboard', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -514,14 +583,14 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
prompt.setMessages([
{ role: 'assistant', content: 'copy me' }
{ role: 'assistant', content: '```py\nprint(1)\n```' }
]);
const copyBtn = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(1)'
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(1)'
) as HTMLButtonElement;
copyBtn.click();
const writeText = (navigator.clipboard.writeText as unknown as jest.Mock);
expect(writeText).toHaveBeenCalledWith('copy me');
expect(writeText).toHaveBeenCalledWith('print(1)');
});
});
+54 -32
View File
@@ -225,40 +225,62 @@ export class OpenCodeInlinePrompt extends Widget {
if (msg.role === 'user') {
wrap.textContent = msg.content;
} else {
// 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);
// Assistant: render the markdown, then for every fenced code
// block (<pre>) attach a small toolbar with Copy / Insert /
// Replace buttons that act on THAT block's code (not the whole
// message). Messages without code fences get no toolbar.
const rendered = document.createElement('div');
rendered.className = 'opencode-msg-content';
rendered.innerHTML = marked.parse(content) as string;
rendered.innerHTML = marked.parse(msg.content) as string;
const codeBlocks = rendered.querySelectorAll('pre');
codeBlocks.forEach(pre => {
const code = pre.querySelector('code');
if (!code) {
return;
}
// Snapshot the code text before we mutate the DOM so the
// buttons act on the exact code inside this block (no
// surrounding ```fences```, no toolbar label text).
const codeText = code.textContent ?? '';
const wrap = document.createElement('div');
wrap.className = 'opencode-code-block';
const toolbar = document.createElement('div');
toolbar.className = 'opencode-code-toolbar';
const mkBtn = (
label: string,
title: string,
onClick: () => void
) => {
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);
})
);
wrap.appendChild(toolbar);
// Move the original <pre> into the wrapper.
pre.parentNode!.insertBefore(wrap, pre);
wrap.appendChild(pre);
});
wrap.appendChild(rendered);
}
this._historyEl.appendChild(wrap);
+29 -7
View File
@@ -111,16 +111,30 @@
color: var(--jp-ui-font-color0, #000);
}
.opencode-inline-prompt .opencode-msg-toolbar {
display: flex;
gap: 4px;
margin-bottom: 4px;
/* Per-code-block toolbar: wraps every rendered <pre> so the Copy /
Insert / Replace buttons live INSIDE the fenced section (not as a
message-level header). */
.opencode-inline-prompt .opencode-code-block {
margin: 6px 0;
}
.opencode-inline-prompt .opencode-msg-btn {
.opencode-inline-prompt .opencode-code-toolbar {
display: flex;
gap: 4px;
padding: 2px 6px;
background: var(--jp-layout-color2, #f0f0f0);
border: 1px solid var(--jp-border-color2, #ccc);
border-bottom: none;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
font-size: var(--jp-ui-font-size0, 11px);
align-items: center;
}
.opencode-inline-prompt .opencode-code-btn {
border: 1px solid var(--jp-border-color2, #ccc);
background: var(--jp-layout-color1, #fff);
padding: 1px 6px;
padding: 0 6px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
@@ -128,11 +142,19 @@
color: var(--jp-ui-font-color1, #333);
}
.opencode-inline-prompt .opencode-msg-btn:hover {
.opencode-inline-prompt .opencode-code-btn:hover {
background: var(--jp-layout-color2, #f7f7f7);
border-color: var(--jp-border-color1, #999);
}
/* The wrapped <pre> connects visually to the toolbar above (no top
border/radius, no top margin) so the block reads as one unit. */
.opencode-inline-prompt .opencode-code-block > pre {
margin-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.opencode-inline-prompt .opencode-inline-history pre {
background: var(--jp-layout-color1, #fff);
padding: 6px 8px;