diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 499c7f4..fdf1a1b 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -660,7 +660,10 @@ vv_ai_profiles_script(); body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {}); finish(); save(); - onTurn(); + // The answer is passed so a page can react to what was said, not just that a turn + // happened — the Scheduler uses it to spot a code block worth offering to the editor. + // Handlers that take no argument are unaffected. + onTurn(j.answer); return; } if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; } diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index d886ab0..1da9496 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -2437,11 +2437,14 @@ if (document.getElementById('vv-sched-ai-chat')) { vvAiLastQ = q; return false; }, - onTurn: () => { vvAiFixArm(); requestAnimationFrame(vvFitRight); }, + onTurn: (answer) => { vvAiFixArm(); vvAiCodeArm(answer); requestAnimationFrame(vvFitRight); }, // 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. onResize: () => requestAnimationFrame(vvFitRight), - onOffer: (kind, yes) => { if (kind === 'fix') vvAiFixAnswer(yes); }, + onOffer: (kind, yes) => { + if (kind === 'fix') vvAiFixAnswer(yes); + if (kind === 'code') vvAiCodeAnswer(yes); + }, }); } @@ -2499,6 +2502,82 @@ let vvAiFixAsk = null; // { scope, symptom } while the offer is open and awa // Armed when a troubleshoot turn finishes. The question is the symptom; the scope is whatever was // being diagnosed. `since` guards against a run that had already completed before the diagnosis // started being read as proof that the diagnosis worked. +// ── AI → editor handoff ────────────────────────────────────────────────────────────────────── +// Asking the assistant for a script and then hand-selecting it out of the transcript is the one +// step in this tab that is pure friction — and a transcript selection loses leading whitespace +// the moment a line wraps, which for bash is not cosmetic. When the editor is open and an answer +// carries a real code block, offer to place it instead. +// +// 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. + +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. +function vvAiEditorOpen() { + const ed = document.getElementById('vv-editor'); + return !!ed && ed.style.display !== 'none'; +} + +function vvAiCodeArm(answer) { + vvAiCodePending = null; + if (!vvSchedChat || !vvAiEditorOpen()) return; + + const code = vvAiCodeExtract(answer); + // A single short line is a mention, not a script. Offering on those turns every answer into a + // 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'); + // 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. + if (!ta || !vvAiEditorOpen()) { + vvSchedChat.note('The editor is no longer open — nothing was inserted.'); + return; + } + + // Commit the pre-insert state immediately. Without the flag, the 600ms coalescing window can + // merge this with whatever was typed just before it, and one Ctrl+Z would take back both. + vvUndoCapture(true); + + const s = ta.selectionStart, e = ta.selectionEnd; + ta.value = ta.value.slice(0, s) + code + ta.value.slice(e); + ta.selectionStart = ta.selectionEnd = s + code.length; + + // A programmatic value change fires no input event, so the highlight overlay, the line gutter + // and the current-line marker all keep rendering the old text until these are called by hand. + vvUndoCapture(true); + vvSyncHlOverlay(); + vvEditorCursorMoved(); + ta.focus(); + + vvSchedChat.note('Inserted at the cursor. Ctrl+Z in the editor takes it back.'); +} + function vvAiFixArm() { if (vvAiProfile !== 'troubleshoot' || !vvAiScope) return; vvAiFixWatch = {