diff --git a/src/web/public/app.js b/src/web/public/app.js index 8f3fbf4d..45be1f43 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1128,22 +1128,40 @@ class CodemanApp { const DIAGRAM_CHAR = /[─-╿▀-▟]/; const tmpl = document.createElement('template'); tmpl.innerHTML = html; + // Every fenced code block gets a positioned wrapper with an action + // toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the + //
 scroll container so its buttons stay put during horizontal
+        // scroll. All blocks get a one-click copy button; ASCII diagrams keep
+        // the additional line-wrap toggle.
         tmpl.content.querySelectorAll('pre > code').forEach((code) => {
-          if (!DIAGRAM_CHAR.test(code.textContent || '')) return;
           const pre = code.parentElement;
-          pre.classList.add('rv-diagram');
+          const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
 
           const wrap = document.createElement('div');
-          wrap.className = 'rv-diagram-wrap';
-
-          const btn = document.createElement('button');
-          btn.className = 'rv-wrap-toggle';
-          btn.type = 'button';
-          btn.setAttribute('aria-label', 'Toggle line wrapping');
-          btn.setAttribute('title', 'Toggle line wrapping');
+          wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
+
+          const actions = document.createElement('div');
+          actions.className = 'rv-code-actions';
+
+          const copyBtn = document.createElement('button');
+          copyBtn.className = 'rv-copy-btn';
+          copyBtn.type = 'button';
+          copyBtn.setAttribute('aria-label', 'Copy code');
+          copyBtn.setAttribute('title', 'Copy code');
+          actions.appendChild(copyBtn);
+
+          if (isDiagram) {
+            pre.classList.add('rv-diagram');
+            const toggle = document.createElement('button');
+            toggle.className = 'rv-wrap-toggle';
+            toggle.type = 'button';
+            toggle.setAttribute('aria-label', 'Toggle line wrapping');
+            toggle.setAttribute('title', 'Toggle line wrapping');
+            actions.appendChild(toggle);
+          }
 
           pre.parentNode.insertBefore(wrap, pre);
-          wrap.appendChild(btn);
+          wrap.appendChild(actions);
           wrap.appendChild(pre);
         });
         return tmpl.innerHTML;
@@ -1162,7 +1180,23 @@ class CodemanApp {
   _bindResponseViewerInteractions(body) {
     if (!body || body.dataset.rvBound === '1') return;
     body.dataset.rvBound = '1';
-    body.addEventListener('click', (ev) => {
+    body.addEventListener('click', async (ev) => {
+      // One-click copy: lift the raw source from the sibling 
.
+      const copyBtn = ev.target.closest('.rv-copy-btn');
+      if (copyBtn) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
+        const ok = code ? await this._copyText(code.textContent || '') : false;
+        copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
+        copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
+        clearTimeout(copyBtn._resetTimer);
+        copyBtn._resetTimer = setTimeout(() => {
+          copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
+        }, 1500);
+        return;
+      }
+
       const btn = ev.target.closest('.rv-wrap-toggle');
       if (!btn) return;
       ev.preventDefault();
@@ -1175,6 +1209,34 @@ class CodemanApp {
     });
   }
 
+  /**
+   * Copy text to the clipboard. Prefers the async Clipboard API (secure
+   * contexts); falls back to a hidden-textarea + execCommand path so copy
+   * still works over plain HTTP. Returns true on success.
+   */
+  async _copyText(text) {
+    if (!text) return false;
+    try {
+      if (navigator.clipboard?.writeText) {
+        await navigator.clipboard.writeText(text);
+        return true;
+      }
+    } catch { /* secure-context write failed — try the legacy path */ }
+    try {
+      const ta = document.createElement('textarea');
+      ta.value = text;
+      ta.setAttribute('readonly', '');
+      ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
+      document.body.appendChild(ta);
+      ta.select();
+      const ok = document.execCommand('copy');
+      document.body.removeChild(ta);
+      return ok;
+    } catch {
+      return false;
+    }
+  }
+
   async toggleResponseViewer() {
     const viewer = document.getElementById('responseViewer');
     const backdrop = document.getElementById('responseViewerBackdrop');
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 4dd21f1c..b96277ef 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -8223,6 +8223,74 @@ kbd {
   content: '⤢';
 }
 
+/* ── Code block one-click copy ──────────────────────────────────────────────
+   Every fenced code block is wrapped in .rv-code-wrap with an action toolbar
+   pinned to its top-right. Regular blocks get the relative positioning here;
+   ASCII diagrams already get it from .rv-diagram-wrap (don't clobber its
+   centering margins). */
+.rv-text .rv-code-wrap:not(.rv-diagram-wrap),
+.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) {
+  position: relative;
+  margin: 1em 0;
+}
+
+.rv-text .rv-code-wrap:not(.rv-diagram-wrap) > pre,
+.response-viewer-body .rv-code-wrap:not(.rv-diagram-wrap) > pre {
+  margin: 0;
+  padding-right: 44px;    /* reserve room for the copy button */
+}
+
+/* Diagrams carry two buttons (copy + wrap toggle) — widen the reserve. */
+.rv-text .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram,
+.response-viewer-body .rv-code-wrap.rv-diagram-wrap > pre.rv-diagram {
+  padding-right: 76px;
+}
+
+.rv-code-actions {
+  position: absolute;
+  top: 6px;
+  right: 6px;
+  display: inline-flex;
+  gap: 4px;
+  z-index: 2;
+}
+
+/* Inside the flex toolbar the wrap toggle flows normally — drop its own pin. */
+.rv-code-actions .rv-wrap-toggle {
+  position: static;
+  top: auto;
+  right: auto;
+}
+
+.rv-copy-btn {
+  width: 28px;
+  height: 24px;
+  padding: 0;
+  border: 1px solid #2f2f45;
+  border-radius: 5px;
+  background: rgba(20, 20, 32, 0.92);
+  color: #8b8b97;
+  font-size: 13px;
+  line-height: 1;
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  transition: color 0.15s, border-color 0.15s;
+}
+
+.rv-copy-btn:hover,
+.rv-copy-btn:active {
+  color: #e0e0ec;
+  border-color: #4a4a65;
+}
+
+.rv-copy-btn::before { content: '\2398'; }            /* ⎘ — matches file-preview copy */
+.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; }
+.rv-copy-btn.rv-copied::before { content: '\2713'; }  /* ✓ */
+.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; }
+.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */
+
 .rv-text ul, .rv-text ol,
 .response-viewer-body > ul, .response-viewer-body > ol {
   margin: 0.6em 0;