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; .vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
overflow-x:auto; margin:8px 0; } overflow-x:auto; margin:8px 0; }
.vv-ai-body pre code { background:none; padding:0; color:#9cc; } .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 { color:#5c7cfa; font-weight:bold; cursor:pointer; }
.vv-ai-cite:hover { text-decoration:underline; } .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 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(); } 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 ──────────────── // ── Minimal markdown, applied strictly after escaping ────────────────
function fmt(text) { function fmt(text) {
let h = esc(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, '<code>$1</code>');
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>'); h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" data-cite="$1">[$1]</span>'); h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" data-cite="$1">[$1]</span>');
@@ -586,9 +636,54 @@ vv_ai_profiles_script();
return; return;
} }
const off = e.target.closest('[data-offer]'); 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 ─────────────────────────────────────────────────────── // ── Ask / poll ───────────────────────────────────────────────────────
function send() { function send() {
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll // Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
+11 -40
View File
@@ -2437,14 +2437,13 @@ if (document.getElementById('vv-sched-ai-chat')) {
vvAiLastQ = q; vvAiLastQ = q;
return false; return false;
}, },
onTurn: (answer) => { vvAiFixArm(); vvAiCodeArm(answer); requestAnimationFrame(vvFitRight); }, onTurn: () => { vvAiFixArm(); requestAnimationFrame(vvFitRight); },
// Expanding changes how much of the panel is left for the views above it. Without this the // Expanding changes how much of the panel is left for the views above it. Without this the
// chat grows downward past the end of the panel instead of upward into it. // chat grows downward past the end of the panel instead of upward into it.
onResize: () => requestAnimationFrame(vvFitRight), onResize: () => requestAnimationFrame(vvFitRight),
onOffer: (kind, yes) => { onOffer: (kind, yes) => { if (kind === 'fix') vvAiFixAnswer(yes); },
if (kind === 'fix') vvAiFixAnswer(yes); // Per-block Insert buttons render only where a page can receive them.
if (kind === 'code') vvAiCodeAnswer(yes); onInsertCode: vvAiInsertCode,
},
}); });
} }
@@ -2511,52 +2510,24 @@ let vvAiFixAsk = null; // { scope, symptom } while the offer is open and awa
// It is an offer, never automatic: the editor usually holds work in progress, and a reply that // It is an offer, never automatic: the editor usually holds work in progress, and a reply that
// silently rewrote it would be far worse than copy/paste. // silently rewrote it would be far worse than copy/paste.
let vvAiCodePending = null;
// Largest fenced block wins when an answer carries several — an explanation typically quotes a
// line or two before giving the whole thing, and the whole thing is what was asked for. A shebang
// beats size outright, since that is unambiguously a script rather than an excerpt.
function vvAiCodeExtract(text) {
if (!text) return '';
const blocks = [];
const re = /```[a-zA-Z0-9_+-]*\n([\s\S]*?)```/g;
let m;
while ((m = re.exec(text)) !== null) blocks.push(m[1].replace(/\s+$/, ''));
if (!blocks.length) return '';
const shebang = blocks.find(b => b.startsWith('#!'));
return shebang || blocks.sort((a, b) => b.length - a.length)[0];
}
// display:'' vs 'none' is how this panel switches views — see vvShowEditor and friends. // display:'' vs 'none' is how this panel switches views — see vvShowEditor and friends.
function vvAiEditorOpen() { function vvAiEditorOpen() {
const ed = document.getElementById('vv-editor'); const ed = document.getElementById('vv-editor');
return !!ed && ed.style.display !== 'none'; return !!ed && ed.style.display !== 'none';
} }
function vvAiCodeArm(answer) { // Called by the chat component's per-block Insert button, with that block's exact text. This
vvAiCodePending = null; // replaced a whole-answer offer: the offer had to guess which block was meant when an answer
if (!vvSchedChat || !vvAiEditorOpen()) return; // carried several, and it asked after every code answer whether or not anything was wanted.
// A button on each block needs no guess and no question.
const code = vvAiCodeExtract(answer); function vvAiInsertCode(code) {
// A single short line is a mention, not a script. Offering on those turns every answer into a if (!code) return;
// question and trains the operator to ignore the buttons.
if (!code || (code.length < 40 && code.indexOf('\n') === -1)) return;
vvAiCodePending = code;
const lines = code.split('\n').length;
vvSchedChat.offer('code', 'Insert that ' + lines + '-line block into the editor at your cursor?');
}
function vvAiCodeAnswer(yes) {
const code = vvAiCodePending;
vvAiCodePending = null;
if (!yes || !code) return;
const ta = document.getElementById('vv-editor-body'); const ta = document.getElementById('vv-editor-body');
// They may have switched views between the answer and the click. Inserting into a hidden // They may have switched views between the answer and the click. Inserting into a hidden
// textarea would look like nothing happened and be discovered much later. // textarea would look like nothing happened and be discovered much later.
if (!ta || !vvAiEditorOpen()) { if (!ta || !vvAiEditorOpen()) {
vvSchedChat.note('The editor is no longer open — nothing was inserted.'); vvSchedChat.note('The editor is not open — nothing was inserted.');
return; return;
} }