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) => `${md}` } + marked: { + // Minimal but realistic mock: produce a real
 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;