Give every code block its own copy and insert, and number the long ones

This commit is contained in:
Gmer4Lfe
2026-08-10 21:12:15 -04:00
parent eb24e60b8c
commit 9adf760c8e
2 changed files with 108 additions and 42 deletions
+97 -2
View File
@@ -159,6 +159,25 @@ function vv_ai_chat_assets(): void {
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
overflow-x:auto; margin:8px 0; }
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
/* ── Code cards ─────────────────────────────────────────────────────────────
Controls are always visible, never hover-revealed. The Monitor and Scheduler run full-time on
15" panels with no pointer near them, so a control that only exists on hover does not exist. */
.vv-ai-code { margin:8px 0; border:1px solid #222; border-radius:4px; overflow:hidden; }
.vv-ai-code pre { margin:0; border:none; border-radius:0; }
.vv-ai-code-h { display:flex; align-items:center; gap:8px; padding:3px 8px;
background:#1b1b1b; border-bottom:1px solid #222; font-size:10px; }
.vv-ai-code-lang { color:#7a9; text-transform:uppercase; letter-spacing:.4px; }
.vv-ai-code-n { color:#555; }
.vv-ai-code-sp { flex:1; }
.vv-ai-code-btn { background:#232323; border:1px solid #333; color:#bbb; font-size:10px;
padding:2px 8px; border-radius:3px; cursor:pointer; line-height:1.5; }
.vv-ai-code-btn:hover { background:#2c2c2c; color:#eee; }
.vv-ai-code-btn.ok { background:#264a26; border-color:#356b35; color:#cfe8cf; }
/* Generated content, so it stays out of textContent — Copy and Insert return the code alone. */
.vv-ai-code.numbered code { counter-reset:vvln; }
.vv-ai-cl { counter-increment:vvln; }
.vv-ai-cl::before { content:counter(vvln); display:inline-block; width:2.6em; margin-right:.8em;
text-align:right; color:#3f3f3f; user-select:none; }
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
.vv-ai-cite:hover { text-decoration:underline; }
@@ -434,10 +453,41 @@ vv_ai_profiles_script();
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
// Long blocks get numbered, because "the error is on line 40" is unusable against a wall of
// unnumbered text. The number is CSS generated content on a per-line span, which keeps it out
// of textContent — so Copy and Insert return the code and never the gutter.
const CODE_NUMBER_FROM = 12;
function codeCard(lang, code) {
const lines = code.split('\n');
const n = lines.length;
const numbered = n >= CODE_NUMBER_FROM;
// Joined on the newline rather than made display:block, so <pre> supplies the line break and
// textContent comes back byte-identical to what the model wrote.
const body = numbered
? lines.map(x => `<span class="vv-ai-cl">${x}</span>`).join('\n')
: code;
let head = '<div class="vv-ai-code-h">';
head += `<span class="vv-ai-code-lang">${lang || 'text'}</span>`;
head += `<span class="vv-ai-code-n">${n} line${n === 1 ? '' : 's'}</span>`;
head += '<span class="vv-ai-code-sp"></span>';
head += '<button type="button" class="vv-ai-code-btn" data-code-copy>Copy</button>';
// Only offered where the page can actually receive it. Without a target this would be a
// button that silently does nothing, which is worse than not having one.
if (o.onInsertCode) {
head += '<button type="button" class="vv-ai-code-btn" data-code-insert>Insert</button>';
}
head += '</div>';
return `<div class="vv-ai-code${numbered ? ' numbered' : ''}">${head}`
+ `<pre><code>${body}</code></pre></div>`;
}
// ── Minimal markdown, applied strictly after escaping ────────────────
function fmt(text) {
let h = esc(text);
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => codeCard(l, c));
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" data-cite="$1">[$1]</span>');
@@ -586,9 +636,54 @@ vv_ai_profiles_script();
return;
}
const off = e.target.closest('[data-offer]');
if (off) answerOffer(+off.dataset.offer, off.dataset.yes === '1');
if (off) { answerOffer(+off.dataset.offer, off.dataset.yes === '1'); return; }
// Code-card buttons. The text is read from the DOM rather than carried in a data attribute:
// a script full of quotes and backslashes interpolated into an attribute is an escaping bug
// waiting to happen, and textContent already holds exactly what the model wrote.
const cbtn = e.target.closest('[data-code-copy],[data-code-insert]');
if (cbtn) {
const card = cbtn.closest('.vv-ai-code');
const code = card ? (card.querySelector('code') || {}).textContent || '' : '';
if (!code) return;
if (cbtn.hasAttribute('data-code-insert')) {
if (o.onInsertCode) o.onInsertCode(code);
flashBtn(cbtn, 'Inserted');
return;
}
// navigator.clipboard needs a secure context. Unraid is routinely reached over plain http
// on the LAN, where it is simply undefined — so the textarea fallback is the path that
// actually runs here, not a legacy nicety.
const done = () => flashBtn(cbtn, 'Copied');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(code).then(done).catch(() => copyFallback(code, done));
} else {
copyFallback(code, done);
}
}
});
function copyFallback(text, done) {
const t = document.createElement('textarea');
t.value = text;
// Off-screen rather than hidden: display:none and visibility:hidden are not selectable, so
// execCommand copies nothing from them.
t.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
document.body.appendChild(t);
t.select();
try { document.execCommand('copy'); done(); } catch (_) {}
t.remove();
}
function flashBtn(btn, msg) {
const was = btn.textContent;
btn.textContent = msg;
btn.classList.add('ok');
setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100);
}
// ── Ask / poll ───────────────────────────────────────────────────────
function send() {
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll