diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts
index ab7cf3c..371c9db 100644
--- a/src/__tests__/opencode_cell_actions.spec.ts
+++ b/src/__tests__/opencode_cell_actions.spec.ts
@@ -49,7 +49,49 @@ jest.mock('@lumino/widgets', () => {
jest.mock('marked', () => ({
__esModule: true,
- marked: { parse: (md: string) => `
for
+ // fenced code blocks (so the per-code-block toolbar
+ // post-processing can find them) and /
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 +=
+ '
' +
+ fenceBuf.join('\n') +
+ '
';
+ inFence = false;
+ continue;
+ }
+ if (inFence) {
+ fenceBuf.push(line);
+ continue;
+ }
+ if (/^# /.test(line)) {
+ html += '' + line.slice(2) + '
';
+ } else if (line.trim()) {
+ html += '' + line + '
';
+ }
+ }
+ return html || '' + md + '
';
+ }
+ }
}));
// 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 + .
expect(asstMsg).not.toBeNull();
- expect(asstMsg.innerHTML).toContain('');
- expect(asstMsg.innerHTML).toContain('# Here you go');
+ expect(asstMsg.innerHTML).toContain('Here you go
');
+ expect(asstMsg.innerHTML).toContain('');
});
- 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 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)');
});
});
diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts
index 4fe3ad3..9d692c8 100644
--- a/src/components/opencode_inline_prompt.ts
+++ b/src/components/opencode_inline_prompt.ts
@@ -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 () 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 into the wrapper.
+ pre.parentNode!.insertBefore(wrap, pre);
+ wrap.appendChild(pre);
+ });
+
wrap.appendChild(rendered);
}
this._historyEl.appendChild(wrap);
diff --git a/style/base.css b/style/base.css
index 30ba745..6c23365 100644
--- a/style/base.css
+++ b/style/base.css
@@ -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 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 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;