@manny-est/node-red-flowpilot 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1510 @@
1
+ // ---- Slash commands ---------------------------------------------------
2
+ // Typed in the compose box and handled entirely client-side: never sent
3
+ // to the model, never recorded in conversation history.
4
+ var HELP_TEXT = "## Captain's briefing\n\n" +
5
+ "You pick the destination, I help you get there. Here's everything on the panel.\n\n" +
6
+ "### Modes (one armed at a time)\n\n" +
7
+ "- **Query** (default) — chat about your flow, ask questions, get explanations. Nothing changes.\n" +
8
+ "- **Generate** — describe a flow and I'll draft it. Review the diff, then Apply.\n" +
9
+ "- **Document** — select node(s) and I'll explain what they do.\n" +
10
+ "- **Modify** — select node(s), tell me what to change, review the diff, then Apply.\n\n" +
11
+ "The compose area glows orange in Query and blue when an Execute mode is armed, so you always know which one you're flying.\n\n" +
12
+ "### Shortcuts\n\n" +
13
+ "- `/help` — show this briefing\n" +
14
+ "- `/generate` — arm Generate mode\n" +
15
+ "- `/document` — arm Document mode\n" +
16
+ "- `/modify` — arm Modify mode\n" +
17
+ "- `/query` — back to Query (disarm)\n" +
18
+ "- `/clear` — start a fresh conversation (clears chat and memory)\n" +
19
+ "- `/history` — open the Flight log (past conversations)\n" +
20
+ "- `/settings` — open the Hangar (providers, behavior, safety)\n\n" +
21
+ "Typing a shortcut with extra text, e.g. `/modify add a debug node`, switches mode and leaves the rest in the box so you can review before sending.\n\n" +
22
+ "- `/demo` — load a sample Generate request (a dad joke flow) into the compose box\n" +
23
+ "- `/feedback` — bug report / feature request info\n" +
24
+ "- `/build` — describe a goal; I'll plan, propose, and walk an iterative build → deploy → debug → review → fix loop with you\n" +
25
+ "- `/compact` — hide labels on the selected node(s) (icon-only); `/expand` restores them. Instant, no AI involved — one Ctrl+Z undoes it.\n" +
26
+ "- `/disable` — disable the selected node(s) (skipped on Deploy); `/enable` re-enables them. Instant, no AI involved — one Ctrl+Z undoes it.\n\n" +
27
+ "### Also worth knowing\n\n" +
28
+ "- Action chips (paper-plane buttons) offer a one-click follow-up — review and send, nothing fires automatically.\n" +
29
+ "- When I ask a clarifying question, I'll often offer quick-reply buttons (plus \"Other\" for your own answer) — clicking one sends it right away.\n" +
30
+ "- \"Pre-flight check\" in the Hangar tests a provider before you rely on it.\n" +
31
+ "- \"Touchdown\"/\"Landed\" notes confirm an applied or imported change. Ctrl+Z undoes an applied change.";
32
+
33
+ // /demo: a ready-made Generate request, used to show off FlowPilot's
34
+ // flow-generation capability end to end (HTTP request, status, debug)
35
+ // with a small, fast-to-generate flow.
36
+ var DEMO_PROMPT = "Using an online dad joke API (e.g. https://icanhazdadjoke.com/ with an \"Accept: application/json\" header), make an API call for a dad joke when triggered by an inject node. Set the node's status to show the joke text, and wire a debug node to output the joke itself.";
37
+
38
+ // /feedback: links back to the repo/issues, shown entirely client-side.
39
+ var FEEDBACK_TEXT = "## Thanks for flying with FlowPilot\n\n" +
40
+ "Bug? Rough edge? Idea for a feature? I'd love to hear about it — the " +
41
+ "human crew reads every report.\n\n" +
42
+ "- **Report an issue**: https://github.com/manny-est/flowpilot/issues\n" +
43
+ "- **Browse the repo**: https://github.com/manny-est/flowpilot\n\n" +
44
+ "A good report travels light but packs the essentials: your Node-RED " +
45
+ "version, the provider/model you're flying with, and the steps to " +
46
+ "reproduce. That's usually enough to get a fix off the ground.\n\n" +
47
+ "Safe travels.";
48
+
49
+ var demoTypeTimer = null;
50
+
51
+ // Registry of all slash commands — drives the autocomplete panel that
52
+ // appears when the user types "/" in the prompt box. Keep in sync with
53
+ // handleSlashCommand below.
54
+ var SLASH_COMMANDS = [
55
+ { cmd: "/generate", desc: "Switch to Generate mode" },
56
+ { cmd: "/document", desc: "Switch to Document mode" },
57
+ { cmd: "/modify", desc: "Switch to Modify mode" },
58
+ { cmd: "/build", desc: "Start a deploy-verify build loop" },
59
+ { cmd: "/chat", desc: "Switch to Chat mode" },
60
+ { cmd: "/query", desc: "Add or toggle a Query intent" },
61
+ { cmd: "/compact", desc: "Compact labels on selected nodes" },
62
+ { cmd: "/expand", desc: "Expand labels on selected nodes" },
63
+ { cmd: "/disable", desc: "Disable selected nodes" },
64
+ { cmd: "/enable", desc: "Enable selected nodes" },
65
+ { cmd: "/demo", desc: "Type in a demo prompt" },
66
+ { cmd: "/help", desc: "Show all available commands" },
67
+ { cmd: "/feedback", desc: "Show feedback info" }
68
+ ];
69
+
70
+ function bindSlashAutocomplete($promptBox) {
71
+ var $wrap = $promptBox.closest(".fp-prompt-wrap");
72
+ var $panel = $('<div id="fp-slash-suggest"></div>').hide();
73
+ $wrap.prepend($panel);
74
+ var activeIndex = -1;
75
+
76
+ function getRows() { return $panel.find(".fp-slash-row"); }
77
+
78
+ function setActive(idx) {
79
+ getRows().removeClass("fp-slash-active");
80
+ activeIndex = idx;
81
+ if (idx >= 0) { getRows().eq(idx).addClass("fp-slash-active"); }
82
+ }
83
+
84
+ function showPanel(partial) {
85
+ var matches = SLASH_COMMANDS.filter(function (c) {
86
+ return c.cmd.indexOf(partial) === 0;
87
+ });
88
+ if (!matches.length) { $panel.hide(); return; }
89
+ $panel.empty();
90
+ matches.forEach(function (c) {
91
+ $('<div class="fp-slash-row">')
92
+ .append($('<span class="fp-slash-cmd">').text(c.cmd))
93
+ .append($('<span class="fp-slash-desc">').text(c.desc))
94
+ .on("mousedown", function (e) {
95
+ e.preventDefault();
96
+ completeWith(c.cmd);
97
+ })
98
+ .appendTo($panel);
99
+ });
100
+ setActive(0);
101
+ $panel.show();
102
+ }
103
+
104
+ function hidePanel() {
105
+ $panel.hide();
106
+ activeIndex = -1;
107
+ }
108
+
109
+ function completeWith(cmd) {
110
+ $promptBox.val(cmd + " ").focus();
111
+ hidePanel();
112
+ }
113
+
114
+ $promptBox.on("input.slashcomplete", function () {
115
+ var val = $promptBox.val();
116
+ if (/^\/\S*$/.test(val)) { showPanel(val); } else { hidePanel(); }
117
+ });
118
+
119
+ $promptBox.on("keydown.slashcomplete", function (e) {
120
+ if (!$panel.is(":visible")) { return; }
121
+ var $rows = getRows();
122
+ if (e.key === "ArrowDown") {
123
+ e.preventDefault();
124
+ setActive(Math.min(activeIndex + 1, $rows.length - 1));
125
+ } else if (e.key === "ArrowUp") {
126
+ e.preventDefault();
127
+ setActive(Math.max(activeIndex - 1, 0));
128
+ } else if (e.key === "Tab" || e.key === "Enter") {
129
+ if (activeIndex >= 0) {
130
+ e.preventDefault();
131
+ completeWith($rows.eq(activeIndex).find(".fp-slash-cmd").text());
132
+ }
133
+ } else if (e.key === "Escape") {
134
+ hidePanel();
135
+ }
136
+ });
137
+
138
+ $promptBox.on("blur.slashcomplete", function () {
139
+ setTimeout(function () { hidePanel(); }, 150);
140
+ });
141
+ }
142
+
143
+ // Streams `text` into the prompt box a few characters at a time, as if
144
+ // it were being typed, then calls `onDone`. Disabled while typing so the
145
+ // user doesn't fight the animation; cancels any in-progress run first.
146
+ function typeIntoPrompt(text, onDone) {
147
+ var $promptBox = el("#fp-prompt");
148
+ if (demoTypeTimer) { clearInterval(demoTypeTimer); demoTypeTimer = null; }
149
+ if (!$promptBox.length) { if (onDone) { onDone(); } return; }
150
+ $promptBox.val("").prop("disabled", true);
151
+ var i = 0;
152
+ var CHARS_PER_TICK = 3;
153
+ demoTypeTimer = setInterval(function () {
154
+ i = Math.min(text.length, i + CHARS_PER_TICK);
155
+ $promptBox.val(text.slice(0, i));
156
+ $promptBox.scrollTop($promptBox[0].scrollHeight);
157
+ if (i >= text.length) {
158
+ clearInterval(demoTypeTimer);
159
+ demoTypeTimer = null;
160
+ $promptBox.prop("disabled", false).focus();
161
+ if (onDone) { onDone(); }
162
+ }
163
+ }, 12);
164
+ }
165
+
166
+ // Returns true if `raw` was a recognized "/command" and has been fully
167
+ // handled (compose box updated, mode/panel switched, etc.) — callers
168
+ // should NOT also dispatch it as a normal chat/generate/document/modify
169
+ // send.
170
+ function handleSlashCommand(raw) {
171
+ var trimmed = (raw || "").trim();
172
+ if (trimmed.charAt(0) !== "/") { return false; }
173
+
174
+ var command = trimmed.split(/\s+/)[0].toLowerCase();
175
+ var rest = trimmed.slice(command.length).trim();
176
+ var $promptBox = el("#fp-prompt");
177
+
178
+ switch (command) {
179
+ case "/help":
180
+ case "/?":
181
+ addMessage("assistant", HELP_TEXT);
182
+ if ($promptBox.length) { $promptBox.val(""); }
183
+ break;
184
+ case "/generate":
185
+ armExecuteAction("generate");
186
+ addMessage("assistant", "Cleared for **Generate** — describe the flow you'd like me to build, then send.");
187
+ if ($promptBox.length) { $promptBox.val(rest); }
188
+ break;
189
+ case "/build":
190
+ armExecuteAction("build");
191
+ addMessage("assistant", "Cleared for **Build** — describe the goal. I'll plan, propose a first step, " +
192
+ "and after you apply/deploy/test it, walk through fix cycles with you until it works or we hit the attempt limit.");
193
+ if ($promptBox.length) { $promptBox.val(rest); }
194
+ break;
195
+ case "/document":
196
+ armExecuteAction("document");
197
+ addMessage("assistant", "Cleared for **Document** — select the node(s) you want explained, then send.");
198
+ if ($promptBox.length) { $promptBox.val(rest); }
199
+ break;
200
+ case "/modify":
201
+ armExecuteAction("modify");
202
+ addMessage("assistant", "Cleared for **Modify** — select the node(s) you want changed, describe the change, then send.");
203
+ if ($promptBox.length) { $promptBox.val(rest); }
204
+ break;
205
+ case "/query":
206
+ case "/chat":
207
+ disarmExecuteAction();
208
+ addMessage("assistant", "Back to **Query** — ask away.");
209
+ if ($promptBox.length) { $promptBox.val(rest); }
210
+ break;
211
+ case "/clear":
212
+ clearChat();
213
+ if ($promptBox.length) { $promptBox.val(""); }
214
+ break;
215
+ case "/history":
216
+ showHistory();
217
+ if ($promptBox.length) { $promptBox.val(""); }
218
+ break;
219
+ case "/settings":
220
+ showSettings();
221
+ if ($promptBox.length) { $promptBox.val(""); }
222
+ break;
223
+ case "/demo":
224
+ armExecuteAction("generate");
225
+ addMessage("assistant", "Cleared for **Generate** — loading a demo request into the compose box.");
226
+ typeIntoPrompt(DEMO_PROMPT, function () {
227
+ el("#fp-send").addClass("fp-send-breathe");
228
+ });
229
+ break;
230
+ case "/feedback":
231
+ addMessage("assistant", FEEDBACK_TEXT);
232
+ if ($promptBox.length) { $promptBox.val(""); }
233
+ break;
234
+ // Deterministic, no LLM round-trip: just invokes Node-RED's own
235
+ // native "show/hide selected node labels" action (RED.actions
236
+ // "core:show-selected-node-labels" / "core:hide-selected-node-
237
+ // labels", confirmed present in both NR4 and NR5 — the same
238
+ // action a right-click context menu item triggers). Reusing it
239
+ // outright means group-member expansion, no-op skipping, and
240
+ // batching every affected node into ONE compound undo step are
241
+ // already handled correctly — nothing to reimplement.
242
+ case "/compact":
243
+ case "/expand":
244
+ // These two need a live RED.view selection + RED.actions.invoke
245
+ // — dead in the pop-out's disconnected window. Relay the raw
246
+ // command to the parent and let it run this exact same case
247
+ // for real, instead of duplicating the selection-check/invoke
248
+ // logic here.
249
+ if (isPopoutContext) {
250
+ if (window.opener && !window.opener.closed) {
251
+ try { window.opener.postMessage({ event: "runSlashCommand", command: command }, location.origin); } catch (e) { /* ignore */ }
252
+ }
253
+ if ($promptBox.length) { $promptBox.val(""); }
254
+ break;
255
+ }
256
+ var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
257
+ var selCount = (sel && sel.nodes) ? sel.nodes.length : 0;
258
+ if (selCount === 0) {
259
+ addMessage("error", "Select one or more nodes first.");
260
+ if ($promptBox.length) { $promptBox.val(""); }
261
+ break;
262
+ }
263
+ if (command === "/expand") {
264
+ RED.actions.invoke("core:show-selected-node-labels");
265
+ addMessage("assistant", "Touchdown — expanded " + selCount +
266
+ " node label" + (selCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
267
+ } else {
268
+ RED.actions.invoke("core:hide-selected-node-labels");
269
+ addMessage("assistant", "Touchdown — compacted " + selCount +
270
+ " node label" + (selCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
271
+ }
272
+ if ($promptBox.length) { $promptBox.val(""); }
273
+ break;
274
+ // Same deterministic pattern as /compact+/expand above, just
275
+ // toggling the "d" (disabled) flag instead of label visibility —
276
+ // Node-RED's own native "core:enable-selected-nodes"/
277
+ // "core:disable-selected-nodes" actions (confirmed present in
278
+ // both NR4 and NR5, the same actions the right-click context menu
279
+ // uses) already batch every affected node into one compound undo
280
+ // step and skip no-ops, so there's nothing to reimplement here.
281
+ case "/disable":
282
+ case "/enable":
283
+ if (isPopoutContext) {
284
+ if (window.opener && !window.opener.closed) {
285
+ try { window.opener.postMessage({ event: "runSlashCommand", command: command }, location.origin); } catch (e) { /* ignore */ }
286
+ }
287
+ if ($promptBox.length) { $promptBox.val(""); }
288
+ break;
289
+ }
290
+ var dSel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
291
+ var dSelCount = (dSel && dSel.nodes) ? dSel.nodes.length : 0;
292
+ if (dSelCount === 0) {
293
+ addMessage("error", "Select one or more nodes first.");
294
+ if ($promptBox.length) { $promptBox.val(""); }
295
+ break;
296
+ }
297
+ if (command === "/enable") {
298
+ RED.actions.invoke("core:enable-selected-nodes");
299
+ addMessage("assistant", "Touchdown — enabled " + dSelCount +
300
+ " node" + (dSelCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
301
+ } else {
302
+ RED.actions.invoke("core:disable-selected-nodes");
303
+ addMessage("assistant", "Touchdown — disabled " + dSelCount +
304
+ " node" + (dSelCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
305
+ }
306
+ if ($promptBox.length) { $promptBox.val(""); }
307
+ break;
308
+ default:
309
+ addMessage("assistant", "Unrecognized command `" + command + "`. Type `/help` for the full list.");
310
+ if ($promptBox.length) { $promptBox.val(""); }
311
+ break;
312
+ }
313
+
314
+ return true;
315
+ }
316
+
317
+
318
+ // ---- Tabbed review + import --------------------------------------------
319
+ // Hands validated flow JSON to Node-RED's own import mechanism — the same
320
+ // path the editor's Import menu uses (confirmed live: RED.view.importNodes
321
+ // exists, attaches the new nodes to the cursor for the user to place with
322
+ // one click, and registers the whole add as a single native undo step).
323
+ // generateIds avoids id clashes with existing nodes, matching the behavior
324
+ // already validated via manual import of a generated flow.
325
+ // onImported: optional, called only after a successful import — used by
326
+ // the /build loop to advance to the "apply" waypoint once the proposal
327
+ // actually lands. Plain Generate passes nothing, so it's a no-op there.
328
+
329
+ // Copies text to the clipboard and gives the triggering button brief
330
+ // "Copied!" feedback. Tries the modern Clipboard API first — but that
331
+ // requires a "secure context" (HTTPS or localhost), which this editor may
332
+ // not be served over — and falls back to the long-supported
333
+ // execCommand("copy") approach via a temporary off-screen textarea, which
334
+ // works regardless of context. Plain clipboard access is a generic browser
335
+ // feature, not an editor capability, so there's nothing Node-RED-specific
336
+ // to reuse here.
337
+ function copyToClipboard($btn, text) {
338
+ var original = $btn.data("fp-label");
339
+ if (original === undefined) {
340
+ original = $btn.text();
341
+ $btn.data("fp-label", original);
342
+ }
343
+
344
+ function showCopied() {
345
+ $btn.text("Copied!");
346
+ setTimeout(function () { $btn.text(original); }, 1500);
347
+ }
348
+
349
+ function legacyCopy() {
350
+ var $ta = $("<textarea>").val(text)
351
+ .css({ position: "fixed", top: "-1000px", left: "-1000px" });
352
+ $("body").append($ta);
353
+ $ta[0].select();
354
+ var ok = false;
355
+ try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
356
+ $ta.remove();
357
+ if (ok) { showCopied(); }
358
+ else { addMessage("error", "Couldn't copy to clipboard."); }
359
+ }
360
+
361
+ if (navigator.clipboard && navigator.clipboard.writeText) {
362
+ navigator.clipboard.writeText(text).then(showCopied, legacyCopy);
363
+ } else {
364
+ legacyCopy();
365
+ }
366
+ }
367
+
368
+ // Tabbed Summary/JSON review for a generated flow, modeled on the look of
369
+ // Node-RED's Export dialog but built from FlowPilot's own bespoke markup
370
+ // (fp-tab) themed with the editor's --red-ui-* variables — see the handoff
371
+ // doc for why we don't reuse Node-RED's internal tab/export classes.
372
+ // Summary lists each node with type warnings flagged inline plus a
373
+ // detail box; JSON shows the raw, copyable flow. Broken wire references
374
+ // block the "Add to workspace" action (the JSON itself is malformed, not
375
+ // a normal "node not installed" situation); type warnings do not — the
376
+ // user is informed and decides whether to proceed or regenerate.
377
+ // buildGoal: present only for the /build loop's first-step proposal
378
+ // (handleBuildResult) — lets the pop-out's Apply tag carry what
379
+ // startBuildLoop needs (the original goal text) alongside the flow,
380
+ // since plain Generate/Document have no goal/loop to start.
381
+
382
+ // ---- Pop-out window (Phase 8.5 C1) ------------------------------------
383
+ // Mirrors how Node-RED 5's own Debug panel pops out (confirmed against
384
+ // @node-red/nodes/core/common/21-debug.html / debug.js): the SAME
385
+ // renderer (this whole file) loads in both windows; canvas-touching
386
+ // pieces (importGeneratedFlow et al.) stay running in the MAIN window
387
+ // always — the pop-out only ever proxies an intent back via
388
+ // postMessage, never calls RED.* itself. addMessage() and everything it
389
+ // calls runs completely unmodified in either window. Unlike NR5's own
390
+ // reference code (which uses "*" everywhere), every postMessage here
391
+ // pins targetOrigin to location.origin.
392
+ //
393
+ // Slice 1: read-only chat mirror. Slice 2: sending chat from the
394
+ // pop-out (now generalized into the "dispatchSend" intent — see
395
+ // dispatchSend() — covering every mode, not just chat). Slice 3
396
+ // (below): a plain Generate/Document review panel's "Add to
397
+ // workspace" button becomes functional in the pop-out too — Modify
398
+ // and the /build loop are NOT covered by THIS mechanism (Modify's diff
399
+ // is computed against LIVE RED.nodes state, which the pop-out doesn't
400
+ // have; the build loop's import has loop-
401
+ // state follow-up that can't be safely re-triggered from a relayed
402
+ // click) — both stay inert-HTML-only for now, known gaps.
403
+
404
+ // Relays one newly-added top-level #fp-messages child to the pop-out by
405
+ // its rendered HTML. Event handlers don't survive serialization, so the
406
+ // mirrored copy is inert (buttons/links render but do nothing) — exactly
407
+ // right for a display-only v1.
408
+ function relayAppendToPopout(html) {
409
+ if (!popoutWindow || popoutWindow.closed) { return; }
410
+ try {
411
+ popoutWindow.postMessage({ event: "appendMessage", html: html }, location.origin);
412
+ } catch (e) { /* pop-out may be navigating/closing — drop silently */ }
413
+ }
414
+
415
+ // Relays a removal (e.g. renderLoopStepper replacing the previous
416
+ // stepper, or the pending "typing" indicator being cleared) so the
417
+ // mirror doesn't accumulate stale copies of elements that get replaced
418
+ // in place rather than appended fresh.
419
+ function relayRemoveToPopout(id) {
420
+ if (!popoutWindow || popoutWindow.closed || !id) { return; }
421
+ try {
422
+ popoutWindow.postMessage({ event: "removeMessage", id: id }, location.origin);
423
+ } catch (e) { /* ignore, same as above */ }
424
+ }
425
+
426
+ // clearChat()'s el("#fp-messages").empty() removes every bubble at
427
+ // once, but the generic MutationObserver relay only relays a removal
428
+ // when the removed node has an `id` (ordinary chat bubbles don't) —
429
+ // so a bulk clear would silently NOT mirror. Explicit, dedicated event
430
+ // instead of trying to make the generic observer handle bulk removal.
431
+ function relayClearMessagesToPopout() {
432
+ if (!popoutWindow || popoutWindow.closed) { return; }
433
+ try {
434
+ popoutWindow.postMessage({ event: "clearMessages" }, location.origin);
435
+ } catch (e) { /* ignore */ }
436
+ }
437
+
438
+ // Mirrors the status-strip's live-selection-dependent pieces into the
439
+ // pop-out — sent as plain text/class values, NOT outerHTML, since
440
+ // those elements sit in the SAME .fp-status-strip as the pop-out's own
441
+ // bound Send/Clear buttons (a blind HTML swap would clobber them).
442
+ // Called from the end of updateSelectionStatus()/updateDebugStatus()
443
+ // (both parent-only in practice — only the main window has a live
444
+ // RED.view.selection() to report on). The debug line is rebuilt from
445
+ // attachedDebugMessages directly rather than read from the DOM, since
446
+ // the live element also contains a "preview" link whose text would
447
+ // otherwise bleed into the relayed string (that link's click-through
448
+ // needs live context data and isn't relayed at all — known v1 gap,
449
+ // same as the Preview JSON link below).
450
+ function relayStatusStripToPopout() {
451
+ if (!popoutWindow || popoutWindow.closed) { return; }
452
+ var debugCount = attachedDebugMessages.length;
453
+ var payload = {
454
+ selectionText: el("#fp-selection-status").text(),
455
+ hasSelection: el("#fp-selection-status").hasClass("fp-has-selection"),
456
+ previewVisible: !el("#fp-preview-nodes").hasClass("fp-hidden"),
457
+ sizeText: el("#fp-size-status").text(),
458
+ sizeHidden: el("#fp-size-status").hasClass("fp-hidden"),
459
+ sizeWarn: el("#fp-size-status").hasClass("fp-size-warn"),
460
+ sizeHigh: el("#fp-size-status").hasClass("fp-size-high"),
461
+ secretsHidden: el("#fp-secrets-status").hasClass("fp-hidden"),
462
+ secretsOff: el("#fp-secrets-status").hasClass("fp-secrets-status-off"),
463
+ secretsTitle: el("#fp-secrets-status").attr("title") || "",
464
+ debugHidden: debugCount === 0,
465
+ debugText: debugCount
466
+ ? ("🐛 " + debugCount + " debug message" + (debugCount === 1 ? "" : "s") + " attached")
467
+ : ""
468
+ };
469
+ try {
470
+ popoutWindow.postMessage({ event: "statusStripSync", data: payload }, location.origin);
471
+ } catch (e) { /* ignore */ }
472
+ }
473
+
474
+ // Started once the pop-out is open. Only watches direct children of
475
+ // #fp-messages (childList) — an existing bubble's content changing in
476
+ // place (e.g. a streaming reply filling in) is NOT relayed in v1; the
477
+ // mirror catches up once the next sibling message is added. Known,
478
+ // accepted limitation for the smallest first slice — not a regression
479
+ // for tool-capable providers, which never stream today anyway.
480
+ function startPopoutRelay() {
481
+ var box = el("#fp-messages")[0];
482
+ if (!box || popoutObserver) { return; }
483
+ popoutObserver = new MutationObserver(function (mutations) {
484
+ mutations.forEach(function (m) {
485
+ m.removedNodes.forEach(function (node) {
486
+ if (node.nodeType === 1 && node.id) { relayRemoveToPopout(node.id); }
487
+ });
488
+ m.addedNodes.forEach(function (node) {
489
+ if (node.nodeType === 1) { relayAppendToPopout(node.outerHTML); }
490
+ });
491
+ });
492
+ });
493
+ popoutObserver.observe(box, { childList: true });
494
+ }
495
+
496
+ function stopPopoutRelay() {
497
+ if (popoutObserver) { popoutObserver.disconnect(); popoutObserver = null; }
498
+ }
499
+
500
+ // Opens (or focuses, if already open — named window target) the
501
+ // detached mirror, mirroring 21-debug.html's window.open call exactly
502
+ // (same options string shape). Sends a one-time snapshot of the
503
+ // CURRENT #fp-messages content once the pop-out finishes loading, then
504
+ // starts the live relay for everything after that point.
505
+ function openPopout() {
506
+ if (popoutWindow && !popoutWindow.closed) {
507
+ popoutWindow.focus();
508
+ return;
509
+ }
510
+ popoutWindow = window.open(
511
+ document.location.toString().replace(/[?#].*$/, "") + "flowpilot/popout/view.html" + document.location.search,
512
+ "flowpilotPopout",
513
+ "menubar=no,location=no,toolbar=no,chrome,height=700,width=480"
514
+ );
515
+ if (!popoutWindow) { return; }
516
+ popoutWindow.onload = function () {
517
+ var html = el("#fp-messages").length ? el("#fp-messages").html() : "";
518
+ try {
519
+ popoutWindow.postMessage({ event: "initialSync", html: html }, location.origin);
520
+ } catch (e) { /* ignore */ }
521
+ startPopoutRelay();
522
+ };
523
+ }
524
+
525
+ // Entry point for the pop-out's own page (lib/popout/view.html, loaded
526
+ // via this SAME script). Builds the SAME cockpit shell as the main
527
+ // window's action-bar/compose/status-strip (same ids/classes, so the
528
+ // existing CSS and functions below apply unmodified) and relays any
529
+ // user-initiated close/reopen back via window.opener so the main
530
+ // window's "the pop-out is closed" state (popoutWindow.closed) stays
531
+ // accurate without polling.
532
+ //
533
+ // Full cockpit parity (2026-06-26): arming/disarming Generate/Document/
534
+ // Modify, Query intents, and every slash command except /compact+
535
+ // /expand are pure local state (setArmedExecuteAction/
536
+ // handleSlashCommand/armQueryIntent have zero RED.* calls) and work
537
+ // completely unmodified here — only dispatchSend()'s FINAL dispatch and
538
+ // /compact+/expand need to relay instead of touching dead RED.* state
539
+ // (see isPopoutContext, checked inside those two functions). Settings
540
+ // are loaded independently via the pop-out's OWN loadSettings() call
541
+ // (plain ajaxJson, zero RED.* coupling) — gives a correct Provider-
542
+ // status line and working custom Query intents for free via the
543
+ // existing fillSettings(), which jQuery no-ops harmlessly on the
544
+ // Settings-panel field ids this pop-out doesn't render this slice.
545
+ //
546
+ // conversationId/conversationHistory/attachedDebugMessages/
547
+ // activeBuildLoop stay PARENT-OWNED — this window never keeps its own
548
+ // copies. Sending (any mode) and Clear Chat are relayed asks; the
549
+ // selection-status strip is a relayed MIRROR (relayStatusStripToPopout,
550
+ // called from the parent's updateSelectionStatus/updateDebugStatus).
551
+
552
+ // Slice 3: relayed HTML for a plain Generate/Document review panel
553
+ // (addGeneratedReview tags these with data-fp-apply-flow — see there
554
+ // for why the /build loop's panels are excluded) carries the flow data
555
+ // right in the markup, since outerHTML serializes every attribute.
556
+ // Re-validating/re-rendering in the pop-out isn't needed (and wouldn't
557
+ // work anyway — RED.nodes.getType has no installed types in this
558
+ // window) — the parent already validated everything before the
559
+ // snapshot was relayed. This just rebinds ONE button to ask the parent
560
+ // to do exactly what it would do if the same button were clicked in
561
+ // the sidebar.
562
+ function bindApplyButtons($scope) {
563
+ // appendMessage's scope IS the tagged panel itself (a single newly
564
+ // relayed top-level element); initialSync's scope is the container
565
+ // around many descendants — cover both with filter()+find().
566
+ $scope.filter("[data-fp-apply-flow]").add($scope.find("[data-fp-apply-flow]")).each(function () {
567
+ var $panel = $(this);
568
+ if ($panel.data("fp-apply-bound")) { return; }
569
+ $panel.data("fp-apply-bound", true);
570
+ var flow;
571
+ try { flow = JSON.parse($panel.attr("data-fp-apply-flow")); } catch (e) { return; }
572
+ $panel.find(".fp-review-actions button.red-ui-button-primary").on("click", function () {
573
+ var $btn = $(this);
574
+ $btn.prop("disabled", true).text("Click the canvas to place…");
575
+ if (!window.opener || window.opener.closed) { return; }
576
+ try {
577
+ window.opener.postMessage({ event: "applyGenerated", flow: flow }, location.origin);
578
+ } catch (e) { /* ignore */ }
579
+ });
580
+ });
581
+ }
582
+
583
+ // Same idea as bindApplyButtons, but for a relayed PLAIN Modify review
584
+ // panel (addModifyReview tags these with data-fp-apply-modify; a
585
+ // /build loop fix gets a separate tag — see bindBuildFixApplyButtons).
586
+ // nodeDiffs/removeNodes/newNodes/newWires already reflect the diff the
587
+ // parent computed against live RED.nodes state at review time; the
588
+ // pop-out doesn't recompute anything, it just asks the parent to run
589
+ // applyInsertions/applyModifications with this exact data, same as a
590
+ // sidebar click would.
591
+ function bindModifyApplyButtons($scope) {
592
+ $scope.filter("[data-fp-apply-modify]").add($scope.find("[data-fp-apply-modify]")).each(function () {
593
+ var $panel = $(this);
594
+ if ($panel.data("fp-apply-modify-bound")) { return; }
595
+ $panel.data("fp-apply-modify-bound", true);
596
+ var applyData;
597
+ try { applyData = JSON.parse($panel.attr("data-fp-apply-modify")); } catch (e) { return; }
598
+ $panel.find(".fp-review-actions button.red-ui-button-primary").on("click", function () {
599
+ var $btn = $(this);
600
+ $btn.prop("disabled", true).text("Applying…");
601
+ if (!window.opener || window.opener.closed) { return; }
602
+ try {
603
+ window.opener.postMessage({ event: "applyModify", data: applyData }, location.origin);
604
+ } catch (e) { /* ignore */ }
605
+ });
606
+ });
607
+ }
608
+
609
+ // /build loop, first proposal: addGeneratedReview tags this panel
610
+ // (onImported set AND a buildGoal — plain Generate/Document panels get
611
+ // data-fp-apply-flow instead, bound above) with the flow plus the
612
+ // original goal text. The parent's "applyBuild" handler runs
613
+ // importGeneratedFlow then startBuildLoop with it, exactly like
614
+ // handleBuildResult's own onImported closure would.
615
+ function bindBuildApplyButtons($scope) {
616
+ $scope.filter("[data-fp-apply-build]").add($scope.find("[data-fp-apply-build]")).each(function () {
617
+ var $panel = $(this);
618
+ if ($panel.data("fp-apply-build-bound")) { return; }
619
+ $panel.data("fp-apply-build-bound", true);
620
+ var applyData;
621
+ try { applyData = JSON.parse($panel.attr("data-fp-apply-build")); } catch (e) { return; }
622
+ $panel.find(".fp-review-actions button.red-ui-button-primary").on("click", function () {
623
+ var $btn = $(this);
624
+ $btn.prop("disabled", true).text("Click the canvas to place…");
625
+ if (!window.opener || window.opener.closed) { return; }
626
+ try {
627
+ window.opener.postMessage({ event: "applyBuild", data: applyData }, location.origin);
628
+ } catch (e) { /* ignore */ }
629
+ });
630
+ });
631
+ }
632
+
633
+ // /build loop, fix iterations: addModifyReview tags this panel with
634
+ // data-fp-apply-build-fix (instead of data-fp-apply-modify) when
635
+ // buildFixInfo was passed — see applyBuildLoopFix. The parent's
636
+ // "applyBuildFix" handler runs applyInsertions/applyBuildLoopFix
637
+ // with the relayed data, same loop bookkeeping a local click would do.
638
+ function bindBuildFixApplyButtons($scope) {
639
+ $scope.filter("[data-fp-apply-build-fix]").add($scope.find("[data-fp-apply-build-fix]")).each(function () {
640
+ var $panel = $(this);
641
+ if ($panel.data("fp-apply-build-fix-bound")) { return; }
642
+ $panel.data("fp-apply-build-fix-bound", true);
643
+ var applyData;
644
+ try { applyData = JSON.parse($panel.attr("data-fp-apply-build-fix")); } catch (e) { return; }
645
+ $panel.find(".fp-review-actions button.red-ui-button-primary").on("click", function () {
646
+ var $btn = $(this);
647
+ $btn.prop("disabled", true).text("Applying…");
648
+ if (!window.opener || window.opener.closed) { return; }
649
+ try {
650
+ window.opener.postMessage({ event: "applyBuildFix", data: applyData }, location.origin);
651
+ } catch (e) { /* ignore */ }
652
+ });
653
+ });
654
+ }
655
+
656
+ // The loop stepper's "Stop build loop" button is relayed the same
657
+ // generic way as any other chat message (renderLoopStepper appends/
658
+ // replaces a #fp-loop-stepper element, which the MutationObserver
659
+ // relay already mirrors via plain add/remove) — but like every other
660
+ // relayed button, it loses its click handler on the way over. Rebind
661
+ // it to ask the parent to do exactly what a local click would.
662
+ function bindStopLoopButton($scope) {
663
+ $scope.filter("#fp-loop-stepper").add($scope.find("#fp-loop-stepper")).each(function () {
664
+ var $stepper = $(this);
665
+ if ($stepper.data("fp-stop-loop-bound")) { return; }
666
+ $stepper.data("fp-stop-loop-bound", true);
667
+ $stepper.find(".fp-loop-actions button").on("click", function () {
668
+ if (!window.opener || window.opener.closed) { return; }
669
+ try {
670
+ window.opener.postMessage({ event: "stopBuildLoop" }, location.origin);
671
+ } catch (e) { /* ignore */ }
672
+ });
673
+ });
674
+ }
675
+
676
+ // The Summary/JSON tab toggle (addGeneratedReview, addModifyReview,
677
+ // and anything else using the same .fp-tabs/.fp-tab-panel pattern) is
678
+ // purely local DOM show/hide — unlike Apply, it needs no parent
679
+ // access at all, so this works for EVERY relayed review panel. Lost
680
+ // the same way Apply's original handler did (relayed HTML has no
681
+ // event listeners) — this just rebinds the toggle.
682
+ function bindTabSwitching($scope) {
683
+ $scope.filter(".fp-tabs").add($scope.find(".fp-tabs")).each(function () {
684
+ var $tabs = $(this);
685
+ if ($tabs.data("fp-tabs-bound")) { return; }
686
+ $tabs.data("fp-tabs-bound", true);
687
+ var $tabButtons = $tabs.find(".fp-tab");
688
+ var $panels = $tabs.siblings(".fp-tab-panel");
689
+ $tabButtons.each(function (i) {
690
+ $(this).on("click", function () {
691
+ $tabButtons.removeClass("fp-tab-active");
692
+ $(this).addClass("fp-tab-active");
693
+ $panels.addClass("fp-hidden");
694
+ $panels.eq(i).removeClass("fp-hidden");
695
+ });
696
+ });
697
+ });
698
+ }
699
+
700
+ // Rebinds the "Attach" buttons in a relayed debug-log panel. The
701
+ // original handlers (closures over debugMessageBuffer entries) don't
702
+ // survive outerHTML serialization — same problem Apply buttons had.
703
+ // Each button carries data-fp-debug-id; clicking relays the entry id
704
+ // to the parent, which finds it in its own debugMessageBuffer and
705
+ // pushes it to attachedDebugMessages (then calls updateDebugStatus,
706
+ // which already calls relayStatusStripToPopout to sync the counter).
707
+ function bindDebugAttachButtons($scope) {
708
+ $scope.filter("[data-fp-debug-id]").add($scope.find("[data-fp-debug-id]")).each(function () {
709
+ var $btn = $(this);
710
+ if ($btn.data("fp-debug-attach-bound")) { return; }
711
+ $btn.data("fp-debug-attach-bound", true);
712
+ var entryId = $btn.attr("data-fp-debug-id");
713
+ $btn.on("click", function () {
714
+ if (!window.opener || window.opener.closed) { return; }
715
+ $btn.prop("disabled", true).text("Attached");
716
+ try { window.opener.postMessage({ event: "attachDebug", entryId: entryId }, location.origin); } catch (e) { /* ignore */ }
717
+ });
718
+ });
719
+ }
720
+
721
+ function bindPromptResize() {
722
+ var PROMPT_MIN_HEIGHT = 88;
723
+ var PROMPT_MAX_HEIGHT = 480;
724
+ var dragStartY = null;
725
+ var dragStartHeight = null;
726
+
727
+ function onDrag(e) {
728
+ if (dragStartY === null) { return; }
729
+ var clientY = e.touches ? e.touches[0].clientY : e.clientY;
730
+ var next = dragStartHeight + (dragStartY - clientY);
731
+ next = Math.min(PROMPT_MAX_HEIGHT, Math.max(PROMPT_MIN_HEIGHT, next));
732
+ el("#fp-prompt").css("height", next + "px");
733
+ e.preventDefault();
734
+ }
735
+
736
+ function endDrag() {
737
+ dragStartY = null;
738
+ el("#fp-prompt-resize").removeClass("fp-resizing");
739
+ $(document)
740
+ .off("mousemove", onDrag).off("mouseup", endDrag)
741
+ .off("touchmove", onDrag).off("touchend", endDrag);
742
+ }
743
+
744
+ function startDrag(e) {
745
+ var clientY = e.touches ? e.touches[0].clientY : e.clientY;
746
+ dragStartY = clientY;
747
+ dragStartHeight = el("#fp-prompt")[0].offsetHeight;
748
+ el("#fp-prompt-resize").addClass("fp-resizing");
749
+ $(document)
750
+ .on("mousemove", onDrag).on("mouseup", endDrag)
751
+ .on("touchmove", onDrag).on("touchend", endDrag);
752
+ e.preventDefault();
753
+ }
754
+
755
+ el("#fp-prompt-resize").on("mousedown touchstart", startDrag);
756
+ }
757
+
758
+ // Matches Node-RED 5's own debug pop-out (debug.js) exactly: the dark/
759
+ // light preference lives in localStorage under "view-dark-theme"
760
+ // ("dark" / "auto" / anything else = light), shared with the main
761
+ // window since both are same-origin. red/style.min.css's dark-mode
762
+ // variable overrides are scoped under the SAME nr-theme-dark class the
763
+ // main editor toggles on <html> — without this, the pop-out always
764
+ // rendered light regardless of the editor's actual theme.
765
+ function applyPopoutTheme() {
766
+ var themeVariant = localStorage.getItem("view-dark-theme");
767
+ var isDark = false;
768
+ if (themeVariant === "dark") {
769
+ isDark = true;
770
+ } else if (themeVariant === "auto") {
771
+ isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
772
+ }
773
+ document.documentElement.classList.toggle("nr-theme-dark", isDark);
774
+ }
775
+
776
+ function initPopout() {
777
+ isPopoutContext = true;
778
+ applyPopoutTheme();
779
+ var content = $(
780
+ '<div id="fp-root">' +
781
+ ' <div class="fp-header">' +
782
+ ' <div class="fp-header-row">' +
783
+ ' <div class="fp-logo">FP</div>' +
784
+ ' <div class="fp-heading">' +
785
+ ' <div class="fp-title">FlowPilot</div>' +
786
+ ' <div class="fp-subtitle">AI flow assistant</div>' +
787
+ ' </div>' +
788
+ ' <div class="fp-view-buttons">' +
789
+ ' <button id="fp-clear-chat" class="red-ui-button red-ui-button-small" type="button" title="Clear chat and start a fresh conversation (resets memory)"><i class="fa fa-eraser"></i></button>' +
790
+ ' <button id="fp-recall" class="red-ui-button red-ui-button-small" type="button" title="Recall: search earlier conversations for the text in the prompt box"><i class="fa fa-search"></i></button>' +
791
+ ' <button id="fp-debug-log" class="red-ui-button red-ui-button-small" type="button" title="Debug log: view recent Debug sidebar output and attach messages as context"><i class="fa fa-bug"></i></button>' +
792
+ ' <button id="fp-show-chat" class="red-ui-button red-ui-button-small" type="button" title="Chat"><i class="fa fa-comments"></i></button>' +
793
+ ' <button id="fp-show-history" class="red-ui-button red-ui-button-small" type="button" title="Flight log — past conversations"><i class="fa fa-history"></i></button>' +
794
+ ' </div>' +
795
+ ' </div>' +
796
+ ' </div>' +
797
+ ' <div id="fp-chat-panel" class="fp-panel">' +
798
+ ' <div id="fp-messages" class="fp-messages"></div>' +
799
+ ' <div class="fp-compose">' +
800
+ ' <div class="fp-action-bar">' +
801
+ ' <div class="fp-action-group">' +
802
+ ' <div id="fp-intents" class="fp-intents fp-intents-query"></div>' +
803
+ ' </div>' +
804
+ ' <div class="fp-action-divider"></div>' +
805
+ ' <div class="fp-action-group">' +
806
+ ' <div class="fp-intents fp-intents-execute">' +
807
+ ' <button id="fp-document" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Document — select nodes, optionally add notes, then hit Send to generate a comment-node explanation"><i class="fa fa-file-text-o"></i></button>' +
808
+ ' <button id="fp-generate" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Generate — describe a flow, then hit Send to draft it"><i class="fa fa-magic"></i></button>' +
809
+ ' <button id="fp-modify" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Modify — select node(s) in the MAIN window, describe the change, then hit Send"><i class="fa fa-pencil"></i></button>' +
810
+ ' </div>' +
811
+ ' </div>' +
812
+ ' </div>' +
813
+ ' <div class="fp-prompt-wrap">' +
814
+ ' <textarea id="fp-prompt" placeholder="Select nodes in the main window for context, or just type a question…"></textarea>' +
815
+ ' <div id="fp-prompt-resize" class="fp-resize-handle" title="Drag to resize"><i class="fa fa-arrows-v"></i></div>' +
816
+ ' </div>' +
817
+ ' <div class="fp-status-strip">' +
818
+ ' <span id="fp-selection-status" class="fp-selection-status">No nodes selected</span>' +
819
+ ' <a href="#" id="fp-preview-nodes" class="fp-preview-link fp-hidden" title="Open this from the main window to see the exact sanitized node JSON">Preview JSON</a>' +
820
+ ' <span id="fp-size-status" class="fp-size-status fp-hidden"></span>' +
821
+ ' <span id="fp-secrets-status" class="fp-secrets-status fp-hidden" title="Context may include node config and code. Don\'t send credentials or proprietary data. Local/private AI recommended.">⚠</span>' +
822
+ ' <span id="fp-debug-status" class="fp-debug-status fp-hidden"></span>' +
823
+ ' <a href="#" id="fp-debug-clear" class="fp-hidden" title="Remove all attached debug messages">✕</a>' +
824
+ ' <span class="fp-status-spacer"></span>' +
825
+ ' <div id="fp-provider-status">Provider: not loaded</div>' +
826
+ ' <button id="fp-clear-prompt" class="red-ui-button" type="button" title="Clear prompt box">Clear</button>' +
827
+ ' <button id="fp-send" class="red-ui-button red-ui-button-primary" type="button">Send</button>' +
828
+ ' </div>' +
829
+ ' </div>' +
830
+ ' </div>' +
831
+ ' <div id="fp-history-panel" class="fp-panel fp-hidden">' +
832
+ ' <div class="fp-form">' +
833
+ ' <div class="fp-settings-section">Flight log — past conversations</div>' +
834
+ ' <div class="fp-consent-hint">Click a conversation to load it back into Chat — ' +
835
+ ' new messages continue that conversation\'s memory. Deleting a conversation ' +
836
+ ' removes its saved transcript permanently.</div>' +
837
+ ' <div class="fp-settings-actions">' +
838
+ ' <button id="fp-history-delete-all" class="red-ui-button red-ui-button-small" type="button" title="Delete all saved conversation transcripts permanently"><i class="fa fa-trash"></i> Delete all</button>' +
839
+ ' </div>' +
840
+ ' <div id="fp-history-list" class="fp-history-list"></div>' +
841
+ ' </div>' +
842
+ ' </div>' +
843
+ '</div>'
844
+ );
845
+ $("#fp-popout-root").append(content);
846
+ $root = content;
847
+
848
+ // Arming/disarming/Send dispatch reuse the EXISTING functions
849
+ // verbatim — see the comment above this function for why that's
850
+ // safe (pure local state except dispatchSend's final step, which
851
+ // checks isPopoutContext itself).
852
+ el("#fp-generate").on("click", function () { setArmedExecuteAction("generate"); });
853
+ el("#fp-document").on("click", function () { setArmedExecuteAction("document"); });
854
+ el("#fp-modify").on("click", function () { setArmedExecuteAction("modify"); });
855
+ el("#fp-send").on("click", function () { dispatchSend(); });
856
+ el("#fp-prompt").on("keydown", function (e) {
857
+ if (e.key === "Enter" && !e.shiftKey) {
858
+ e.preventDefault();
859
+ dispatchSend();
860
+ }
861
+ });
862
+ el("#fp-clear-prompt").on("click", function () {
863
+ el("#fp-prompt").val("").focus();
864
+ });
865
+ // Clear Chat resets PARENT-owned conversationId/conversationHistory/
866
+ // activeBuildLoop — always relayed, never run locally (this window
867
+ // keeps no conversation state of its own to reset).
868
+ el("#fp-clear-chat").on("click", function () {
869
+ if (window.opener && !window.opener.closed) {
870
+ try { window.opener.postMessage({ event: "clearChat" }, location.origin); } catch (e) { /* ignore */ }
871
+ }
872
+ });
873
+ el("#fp-show-chat").on("click", showChat);
874
+ el("#fp-show-history").on("click", showHistory);
875
+ el("#fp-history-delete-all").on("click", deleteAllConversations);
876
+ el("#fp-recall").on("click", recallSearch);
877
+ el("#fp-debug-log").on("click", function () {
878
+ if (window.opener && !window.opener.closed) {
879
+ try { window.opener.postMessage({ event: "requestDebugBuffer" }, location.origin); } catch (e) { /* ignore */ }
880
+ }
881
+ });
882
+ el("#fp-debug-clear").on("click", function (ev) {
883
+ ev.preventDefault();
884
+ if (window.opener && !window.opener.closed) {
885
+ try { window.opener.postMessage({ event: "clearAttachedDebug" }, location.origin); } catch (e) { /* ignore */ }
886
+ }
887
+ });
888
+ el("#fp-preview-nodes").on("click", function (ev) {
889
+ ev.preventDefault();
890
+ addMessage("error", "Preview JSON isn't available in the pop-out yet — open it from the main window.");
891
+ });
892
+ bindPromptResize();
893
+ bindSlashAutocomplete(el("#fp-prompt"));
894
+
895
+ // Built-ins show immediately; loadSettings()'s fillSettings() call
896
+ // re-renders once custom intents (if any) are loaded, same
897
+ // two-step sequence initMainWindow uses.
898
+ renderIntents(el("#fp-intents"));
899
+ loadSettings();
900
+
901
+ window.addEventListener("message", function (evt) {
902
+ if (evt.origin !== location.origin) { return; }
903
+ var data = evt.data || {};
904
+ if (data.event === "initialSync") {
905
+ el("#fp-messages").html(data.html);
906
+ bindApplyButtons(el("#fp-messages"));
907
+ bindModifyApplyButtons(el("#fp-messages"));
908
+ bindBuildApplyButtons(el("#fp-messages"));
909
+ bindBuildFixApplyButtons(el("#fp-messages"));
910
+ bindStopLoopButton(el("#fp-messages"));
911
+ bindTabSwitching(el("#fp-messages"));
912
+ bindDebugAttachButtons(el("#fp-messages"));
913
+ scrollMessagesToBottom(true);
914
+ } else if (data.event === "appendMessage") {
915
+ el("#fp-messages").append(data.html);
916
+ bindApplyButtons(el("#fp-messages").children().last());
917
+ bindModifyApplyButtons(el("#fp-messages").children().last());
918
+ bindBuildApplyButtons(el("#fp-messages").children().last());
919
+ bindBuildFixApplyButtons(el("#fp-messages").children().last());
920
+ bindStopLoopButton(el("#fp-messages").children().last());
921
+ bindTabSwitching(el("#fp-messages").children().last());
922
+ bindDebugAttachButtons(el("#fp-messages").children().last());
923
+ scrollMessagesToBottom();
924
+ } else if (data.event === "removeMessage") {
925
+ el("#" + data.id).remove();
926
+ } else if (data.event === "clearMessages") {
927
+ el("#fp-messages").empty();
928
+ } else if (data.event === "debugBufferSnapshot") {
929
+ showChat();
930
+ var entries = data.entries || [];
931
+ var attachedSet = {};
932
+ (data.attachedIds || []).forEach(function (id) { attachedSet[id] = true; });
933
+ if (!entries.length) {
934
+ addMessage("assistant", "No debug messages captured yet. Trigger a flow with a Debug node wired to the sidebar, then try again.");
935
+ return;
936
+ }
937
+ var $dbgMsg = $("<div>").addClass("fp-message fp-recall");
938
+ $("<div>").addClass("fp-label").text("DEBUG LOG").appendTo($dbgMsg);
939
+ $("<div>").addClass("fp-debug-warning").text("Debug payloads can contain credentials from connected " +
940
+ "systems. Common secret patterns are redacted automatically, but review before attaching.").appendTo($dbgMsg);
941
+ entries.forEach(function (entry) {
942
+ var $item = $("<div>").addClass("fp-recall-item");
943
+ var when = new Date(entry.timestamp).toLocaleTimeString();
944
+ var meta = when + " · " + entry.name + (entry.topic ? " · topic: " + entry.topic : "");
945
+ $("<div>").addClass("fp-recall-meta").text(meta).appendTo($item);
946
+ $("<div>").addClass("fp-recall-text").text(entry.previewValue).appendTo($item);
947
+ var already = !!attachedSet[entry.id];
948
+ var $btn = $("<button>").addClass("fp-recall-use red-ui-button red-ui-button-small")
949
+ .attr("type", "button").prop("disabled", already).text(already ? "Attached" : "Attach");
950
+ $btn.on("click", function () {
951
+ $btn.prop("disabled", true).text("Attached");
952
+ if (window.opener && !window.opener.closed) {
953
+ try { window.opener.postMessage({ event: "attachDebug", entryId: entry.id }, location.origin); } catch (e) { /* ignore */ }
954
+ }
955
+ });
956
+ $item.append($btn);
957
+ $dbgMsg.append($item);
958
+ });
959
+ el("#fp-messages").append($dbgMsg);
960
+ scrollMessagesToBottom();
961
+ } else if (data.event === "statusStripSync") {
962
+ var s = data.data || {};
963
+ el("#fp-selection-status").text(s.selectionText || "").toggleClass("fp-has-selection", !!s.hasSelection);
964
+ el("#fp-preview-nodes").toggleClass("fp-hidden", !s.previewVisible);
965
+ el("#fp-size-status").text(s.sizeText || "")
966
+ .toggleClass("fp-hidden", !!s.sizeHidden)
967
+ .toggleClass("fp-size-warn", !!s.sizeWarn)
968
+ .toggleClass("fp-size-high", !!s.sizeHigh);
969
+ el("#fp-secrets-status").toggleClass("fp-hidden", !!s.secretsHidden)
970
+ .toggleClass("fp-secrets-status-off", !!s.secretsOff)
971
+ .attr("title", s.secretsTitle || "");
972
+ el("#fp-debug-status").text(s.debugText || "").toggleClass("fp-hidden", !!s.debugHidden);
973
+ el("#fp-debug-clear").toggleClass("fp-hidden", !!s.debugHidden);
974
+ } else if (data.event === "recallResults") {
975
+ hidePending();
976
+ setBusy(false);
977
+ renderRecallResults(data.results || []);
978
+ } else if (data.event === "recallError") {
979
+ hidePending();
980
+ setBusy(false);
981
+ addMessage("error", data.msg || "Recall search failed.");
982
+ } else if (data.event === "conversationList") {
983
+ renderHistoryList(data.conversations || []);
984
+ } else if (data.event === "settingsLoaded") {
985
+ fillSettings(data.settings);
986
+ renderIntents(el("#fp-intents"));
987
+ updateSelectionStatus();
988
+ }
989
+ });
990
+ }
991
+
992
+ // ---- Plugin registration --------------------------------------------
993
+
994
+ function initMainWindow() {
995
+ // If onadd has already run this session, do nothing. This is the
996
+ // primary fix for the duplicate #fp-root: the second invocation
997
+ // returns before building or inserting any DOM.
998
+ if (initialised) { return; }
999
+ initialised = true;
1000
+
1001
+ // Belt-and-suspenders: clear any orphan left by a prior version
1002
+ // before this guard existed. Harmless once the guard is in place.
1003
+ try { RED.sidebar.removeTab("flowpilot"); } catch (e) {}
1004
+ $("#fp-root").remove();
1005
+
1006
+ var content = $(
1007
+ '<div id="fp-root">' +
1008
+ ' <div class="fp-header">' +
1009
+ ' <div class="fp-header-row">' +
1010
+ ' <div class="fp-logo">FP</div>' +
1011
+ ' <div class="fp-heading">' +
1012
+ ' <div class="fp-title">FlowPilot</div>' +
1013
+ ' <div class="fp-subtitle">AI flow assistant</div>' +
1014
+ ' </div>' +
1015
+ ' <div class="fp-view-buttons">' +
1016
+ ' <button id="fp-clear-chat" class="red-ui-button red-ui-button-small" type="button" title="Clear chat and start a fresh conversation (resets memory)"><i class="fa fa-eraser"></i></button>' +
1017
+ ' <button id="fp-recall" class="red-ui-button red-ui-button-small" type="button" title="Recall: search earlier conversations for the text in the prompt box"><i class="fa fa-search"></i></button>' +
1018
+ ' <button id="fp-debug-log" class="red-ui-button red-ui-button-small" type="button" title="Debug log: view recent Debug sidebar output and attach messages as context"><i class="fa fa-bug"></i></button>' +
1019
+ ' <button id="fp-show-chat" class="red-ui-button red-ui-button-small" type="button" title="Chat"><i class="fa fa-comments"></i></button>' +
1020
+ ' <button id="fp-show-history" class="red-ui-button red-ui-button-small" type="button" title="Flight log — past conversations"><i class="fa fa-history"></i></button>' +
1021
+ ' <button id="fp-popout" class="red-ui-button red-ui-button-small" type="button" title="Open in a separate window (read-only mirror — v1)"><i class="fa fa-external-link"></i></button>' +
1022
+ ' <button id="fp-show-settings" class="red-ui-button red-ui-button-small" type="button" title="Settings"><i class="fa fa-cog"></i></button>' +
1023
+ ' </div>' +
1024
+ ' </div>' +
1025
+ ' </div>' +
1026
+
1027
+ ' <div id="fp-chat-panel" class="fp-panel">' +
1028
+ ' <div id="fp-dev-banner" class="fp-warning">' +
1029
+ ' <strong>Development/test only.</strong> ' +
1030
+ ' Anything you send may leave this Node-RED instance. ' +
1031
+ ' Don\'t include credentials or proprietary data; local/private AI recommended.' +
1032
+ ' </div>' +
1033
+ ' <div id="fp-messages" class="fp-messages"></div>' +
1034
+ ' <div class="fp-compose">' +
1035
+ ' <div class="fp-action-bar">' +
1036
+ ' <div class="fp-action-group">' +
1037
+ ' <div id="fp-intents" class="fp-intents fp-intents-query"></div>' +
1038
+ ' </div>' +
1039
+ ' <div class="fp-action-divider"></div>' +
1040
+ ' <div class="fp-action-group">' +
1041
+ ' <div class="fp-intents fp-intents-execute">' +
1042
+ ' <button id="fp-document" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Document — select nodes, optionally add notes, then hit Send to generate a comment-node explanation"><i class="fa fa-file-text-o"></i></button>' +
1043
+ ' <button id="fp-generate" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Generate — describe a flow, then hit Send to draft it"><i class="fa fa-magic"></i></button>' +
1044
+ ' <button id="fp-modify" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Modify — select node(s), describe the change, then hit Send"><i class="fa fa-pencil"></i></button>' +
1045
+ ' </div>' +
1046
+ ' </div>' +
1047
+ ' </div>' +
1048
+ ' <div class="fp-prompt-wrap">' +
1049
+ ' <textarea id="fp-prompt" placeholder="Select nodes for context, or just type a question…"></textarea>' +
1050
+ ' <div id="fp-prompt-resize" class="fp-resize-handle" title="Drag to resize"><i class="fa fa-arrows-v"></i></div>' +
1051
+ ' </div>' +
1052
+ ' <div class="fp-status-strip">' +
1053
+ ' <span id="fp-selection-status" class="fp-selection-status">No nodes selected</span>' +
1054
+ ' <a href="#" id="fp-preview-nodes" class="fp-preview-link fp-hidden" title="Show the exact sanitized node JSON that will be sent">Preview JSON</a>' +
1055
+ ' <span id="fp-size-status" class="fp-size-status fp-hidden"></span>' +
1056
+ ' <span id="fp-secrets-status" class="fp-secrets-status fp-hidden" title="Context may include node config and code. Don\'t send credentials or proprietary data. Local/private AI recommended.">⚠</span>' +
1057
+ ' <span id="fp-debug-status" class="fp-debug-status fp-hidden"></span>' +
1058
+ ' <span class="fp-status-spacer"></span>' +
1059
+ ' <div id="fp-provider-status">Provider: not loaded</div>' +
1060
+ ' <button id="fp-clear-prompt" class="red-ui-button" type="button" title="Clear prompt box">Clear</button>' +
1061
+ ' <button id="fp-send" class="red-ui-button red-ui-button-primary" type="button">Send</button>' +
1062
+ ' </div>' +
1063
+ ' </div>' +
1064
+ ' </div>' +
1065
+
1066
+ ' <div id="fp-settings-panel" class="fp-panel fp-hidden">' +
1067
+ ' <div class="fp-form">' +
1068
+
1069
+ ' <details class="fp-settings-group" open>' +
1070
+ ' <summary title="Hangar — where your AI providers are configured">Providers</summary>' +
1071
+ ' <div class="fp-warning">' +
1072
+ ' <strong>Provider settings.</strong><br>' +
1073
+ ' Stored locally under the Node-RED user directory in <code>flowpilot/settings.json</code>.' +
1074
+ ' </div>' +
1075
+ ' <label>Active provider</label>' +
1076
+ ' <select id="fp-provider-select"></select>' +
1077
+ ' <div class="fp-provider-actions">' +
1078
+ ' <button id="fp-add-provider" class="red-ui-button red-ui-button-small" type="button">+ Add</button>' +
1079
+ ' <button id="fp-remove-provider" class="red-ui-button red-ui-button-small" type="button">Remove</button>' +
1080
+ ' </div>' +
1081
+ ' <label>Provider Name</label>' +
1082
+ ' <input id="fp-provider-name" type="text" placeholder="LocalAI">' +
1083
+ ' <label>Base URL</label>' +
1084
+ ' <input id="fp-base-url" type="text" placeholder="http://localhost:8080">' +
1085
+ ' <label>API Key</label>' +
1086
+ ' <input id="fp-api-key" type="password" placeholder="Optional">' +
1087
+ ' <label>Model</label>' +
1088
+ ' <input id="fp-model" type="text" list="fp-model-options" placeholder="Model name for this provider">' +
1089
+ ' <datalist id="fp-model-options"></datalist>' +
1090
+ ' <div id="fp-models-hint" class="fp-consent-hint fp-hidden"></div>' +
1091
+ ' <label>Temperature</label>' +
1092
+ ' <input id="fp-temperature" type="number" min="0" max="2" step="0.1" placeholder="0.2">' +
1093
+ ' <div class="fp-consent-hint">Controls randomness. Lower (e.g. 0.2) is more ' +
1094
+ ' focused and consistent; higher is more creative and varied. 0.2 is a good ' +
1095
+ ' default for flow generation.</div>' +
1096
+ ' <div class="fp-settings-actions">' +
1097
+ ' <button id="fp-test-provider" class="red-ui-button" type="button" title="Pre-flight check — save and send a quick test to this provider">Pre-flight check</button>' +
1098
+ ' <button id="fp-refresh-models" class="red-ui-button" type="button" title="Save settings, then fetch this provider\'s model list via GET /v1/models">Refresh models</button>' +
1099
+ ' </div>' +
1100
+ ' </details>' +
1101
+
1102
+ ' <details class="fp-settings-group">' +
1103
+ ' <summary>Behavior</summary>' +
1104
+ ' <div class="fp-settings-section">System Prompt</div>' +
1105
+ ' <textarea id="fp-system-prompt"></textarea>' +
1106
+ ' <div class="fp-settings-actions">' +
1107
+ ' <button id="fp-reset-system-prompt" class="red-ui-button" type="button" title="Replace this text with FlowPilot\'s current built-in default — useful after an update adds new instructions">Reset to default</button>' +
1108
+ ' </div>' +
1109
+
1110
+ ' <div class="fp-settings-section">Personality</div>' +
1111
+ ' <label>Persona intensity: <span id="fp-persona-value">3</span>/10</label>' +
1112
+ ' <input id="fp-persona-intensity" type="range" min="1" max="10" step="1">' +
1113
+ ' <div id="fp-persona-label" class="fp-consent-hint"></div>' +
1114
+ ' <div class="fp-consent-hint">Chat only. Scales the AI\'s voice at greetings, ' +
1115
+ ' capability questions, and brief transitions — 1 is a plain Node-RED engineer, ' +
1116
+ ' 10 is a comically over-the-top airline captain who happens to be a Node-RED ' +
1117
+ ' expert. Explanations, troubleshooting, and errors always stay plain regardless ' +
1118
+ ' of this setting. Generate, Document, and Modify are unaffected.</div>' +
1119
+
1120
+ ' <div class="fp-settings-section">Conversation memory</div>' +
1121
+ ' <label>Remember last N exchanges</label>' +
1122
+ ' <input id="fp-history-max" type="number" min="0" step="1" placeholder="10">' +
1123
+ ' <div class="fp-consent-hint">How much of the visible chat is sent back to the ' +
1124
+ ' AI as conversation history with each request (0 = no memory). Older messages ' +
1125
+ ' drop off and the AI is told when that happens. Clear chat (eraser icon) ' +
1126
+ ' resets this entirely.</div>' +
1127
+ ' <label class="fp-checkbox-row">' +
1128
+ ' <input id="fp-streaming-enabled" type="checkbox"> ' +
1129
+ ' Stream chat replies as they generate' +
1130
+ ' </label>' +
1131
+ ' <div class="fp-consent-hint">Applies to every mode (Chat, Generate, ' +
1132
+ ' Document, Modify, Build) the same way — but only when the active ' +
1133
+ ' provider doesn\'t support tool/function calling. Tool-capable ' +
1134
+ ' providers always wait for the full response regardless of this ' +
1135
+ ' setting, since the read-tool loop has no streaming variant.</div>' +
1136
+
1137
+ ' <div class="fp-settings-section">Request timeout</div>' +
1138
+ ' <label>Give up after (seconds)</label>' +
1139
+ ' <input id="fp-request-timeout" type="number" min="5" step="5" placeholder="180">' +
1140
+ ' <div class="fp-consent-hint">How long to wait for a provider response before ' +
1141
+ ' giving up. Raise this if you\'re running a large local model on slow hardware ' +
1142
+ ' (e.g. Ollama without a GPU) and seeing timeout errors.</div>' +
1143
+
1144
+ ' <div class="fp-settings-section">Agentic build loop</div>' +
1145
+ ' <label>Max build/fix attempts</label>' +
1146
+ ' <input id="fp-agent-loop-max-iterations" type="number" min="1" max="20" step="1" placeholder="5">' +
1147
+ ' <div class="fp-consent-hint">When using the deploy-verify loop, how many build → deploy → ' +
1148
+ ' test → fix cycles to try before stopping with an honest "couldn\'t fully verify" ' +
1149
+ ' instead of proposing another fix.</div>' +
1150
+ ' <label class="fp-checkbox-row">' +
1151
+ ' <input id="fp-loop-hold-step" type="checkbox"> ' +
1152
+ ' Hold at each loop checkpoint (wait for confirmation before AI review)' +
1153
+ ' </label>' +
1154
+ ' <div class="fp-consent-hint">When checked, the loop pauses after auto-attaching debug ' +
1155
+ ' output and shows a "Continue review / Stop" prompt before sending to the AI. ' +
1156
+ ' When unchecked (default), the loop advances automatically.</div>' +
1157
+
1158
+ ' <div class="fp-settings-section">Custom intent buttons</div>' +
1159
+ ' <div class="fp-consent-hint">Add your own one-click prompt buttons. ' +
1160
+ ' They appear next to the built-in ones above the prompt.</div>' +
1161
+ ' <div id="fp-custom-intents" class="fp-custom-intents"></div>' +
1162
+ ' <label>New button label</label>' +
1163
+ ' <input id="fp-new-intent-label" type="text" placeholder="e.g. Security review">' +
1164
+ ' <label>Instruction text</label>' +
1165
+ ' <textarea id="fp-new-intent-text" placeholder="What should this button ask the AI to do?"></textarea>' +
1166
+ ' <div class="fp-settings-actions">' +
1167
+ ' <button id="fp-add-intent" class="red-ui-button" type="button">Add button</button>' +
1168
+ ' </div>' +
1169
+ ' </details>' +
1170
+
1171
+ ' <details class="fp-settings-group">' +
1172
+ ' <summary>Context &amp; Safety</summary>' +
1173
+ ' <div class="fp-settings-section">Context size warnings</div>' +
1174
+ ' <label>Warn above (estimated tokens)</label>' +
1175
+ ' <input id="fp-warn-tokens" type="number" min="0" step="500" placeholder="4000">' +
1176
+ ' <label>Strong warning above (estimated tokens)</label>' +
1177
+ ' <input id="fp-high-tokens" type="number" min="0" step="500" placeholder="8000">' +
1178
+
1179
+ ' <div class="fp-settings-section">Risk warnings</div>' +
1180
+ ' <label class="fp-checkbox-row">' +
1181
+ ' <input id="fp-suppress-warnings" type="checkbox"> ' +
1182
+ ' Hide the recurring credentials/size warning bar' +
1183
+ ' </label>' +
1184
+ ' <div class="fp-consent-hint">To hide the warning, check the box and type ' +
1185
+ ' <strong>I understand the risk</strong> below. ' +
1186
+ ' Anything you send may leave this Node-RED instance.</div>' +
1187
+ ' <input id="fp-suppress-confirm" type="text" placeholder="Type: I understand the risk">' +
1188
+
1189
+ ' <div class="fp-settings-section">Redaction</div>' +
1190
+ ' <label class="fp-checkbox-row">' +
1191
+ ' <input id="fp-redaction-disabled" type="checkbox"> ' +
1192
+ ' Disable secret-shaped-value redaction' +
1193
+ ' </label>' +
1194
+ ' <div class="fp-consent-hint">By default, values that look like secrets ' +
1195
+ ' (passwords, tokens, API keys) are replaced with a placeholder before sending. ' +
1196
+ ' This opt-out is intended for environments using local or private AIs. Debug ' +
1197
+ ' nodes and context may share confidential data, including secret keys, and ' +
1198
+ ' credentials. Use at your own risk! Node-RED\'s own credential store is never ' +
1199
+ ' sent either way. To disable, check the box and type ' +
1200
+ ' <strong>disable redaction</strong> below.</div>' +
1201
+ ' <input id="fp-redaction-confirm" type="text" placeholder="Type: disable redaction">' +
1202
+ ' </details>' +
1203
+
1204
+ ' <div class="fp-settings-actions">' +
1205
+ ' <button id="fp-save-settings" class="red-ui-button red-ui-button-primary" type="button">Save settings</button>' +
1206
+ ' <span id="fp-save-status" class="fp-save-status fp-hidden"></span>' +
1207
+ ' </div>' +
1208
+ ' </div>' +
1209
+ ' </div>' +
1210
+
1211
+ ' <div id="fp-history-panel" class="fp-panel fp-hidden">' +
1212
+ ' <div class="fp-form">' +
1213
+ ' <div class="fp-settings-section">Flight log — past conversations</div>' +
1214
+ ' <div class="fp-consent-hint">Click a conversation to load it back into Chat — ' +
1215
+ ' new messages continue that conversation\'s memory. Deleting a conversation ' +
1216
+ ' removes its saved transcript permanently.</div>' +
1217
+ ' <div class="fp-settings-actions">' +
1218
+ ' <button id="fp-history-delete-all" class="red-ui-button red-ui-button-small" type="button" title="Delete all saved conversation transcripts permanently"><i class="fa fa-trash"></i> Delete all</button>' +
1219
+ ' </div>' +
1220
+ ' <div id="fp-history-list" class="fp-history-list"></div>' +
1221
+ ' </div>' +
1222
+ ' </div>' +
1223
+ '</div>'
1224
+ );
1225
+
1226
+ // Keep the closure reference to the *inserted* content.
1227
+ $root = content;
1228
+
1229
+ // ---- Settings accordion: only one section expanded at a time.
1230
+ // Bind once on the parent (event delegation) rather than per-summary.
1231
+ content.find(".fp-settings-group > summary").on("click", function () {
1232
+ // Close all other details elements
1233
+ content.find(".fp-settings-group").not($(this).closest(".fp-settings-group")).removeAttr("open");
1234
+ });
1235
+
1236
+ // ---- Bind events here, after the DOM exists. No inline onclick.
1237
+ content.find("#fp-show-chat").on("click", showChat);
1238
+ content.find("#fp-show-settings").on("click", showSettings);
1239
+ content.find("#fp-show-history").on("click", showHistory);
1240
+ content.find("#fp-popout").on("click", openPopout);
1241
+ content.find("#fp-history-delete-all").on("click", deleteAllConversations);
1242
+ content.find("#fp-clear-chat").on("click", clearChat);
1243
+ content.find("#fp-recall").on("click", recallSearch);
1244
+ content.find("#fp-debug-log").on("click", showDebugMessages);
1245
+ content.find("#fp-preview-nodes").on("click", function (ev) {
1246
+ ev.preventDefault();
1247
+ showJsonPreview("Node JSON preview — exactly what will be sent", resolveCurrentSelectionContext());
1248
+ });
1249
+
1250
+ // Subscribe once to the same RED.comms "debug" topic the built-in
1251
+ // Debug sidebar uses, to buffer recent messages locally for
1252
+ // optional attachment (showDebugMessages/attachDebugContext).
1253
+ // Nothing is sent to the backend until the user explicitly
1254
+ // attaches a message and sends a request.
1255
+ try { RED.comms.subscribe("debug", onDebugMessage); } catch (e) { /* comms unavailable */ }
1256
+
1257
+ // Track whether the user is scrolled to the bottom of the chat,
1258
+ // so "Cruising…"/streaming updates only auto-follow when they
1259
+ // haven't scrolled up to read earlier messages.
1260
+ content.find("#fp-messages").on("scroll", function () {
1261
+ fpChatSnappedToBottom = (this.scrollHeight - this.scrollTop - this.clientHeight) <= FP_SCROLL_SNAP_PX;
1262
+ });
1263
+
1264
+ // Delegated: code blocks are injected as raw HTML (renderMarkdown
1265
+ // -> .html()), so individual buttons never exist at bind time.
1266
+ content.find("#fp-messages").on("click", ".fp-code-copy", function () {
1267
+ var $btn = $(this);
1268
+ var $pre = el("#" + $btn.attr("data-code-id"));
1269
+ if ($pre.length) { copyToClipboard($btn, $pre.text()); }
1270
+ });
1271
+
1272
+ content.find("#fp-clear-prompt").on("click", function () {
1273
+ el("#fp-prompt").val("").focus();
1274
+ });
1275
+
1276
+ content.find("#fp-send").on("click", function () {
1277
+ dispatchSend();
1278
+ });
1279
+ content.find("#fp-generate").on("click", function () {
1280
+ setArmedExecuteAction("generate");
1281
+ });
1282
+ content.find("#fp-document").on("click", function () {
1283
+ setArmedExecuteAction("document");
1284
+ });
1285
+ content.find("#fp-modify").on("click", function () {
1286
+ setArmedExecuteAction("modify");
1287
+ });
1288
+
1289
+ // Close the "more query actions" dropdown on any click outside it.
1290
+ // Bound once here (onadd guard above) rather than per-render.
1291
+ $(document).on("click.fpIntentMenu", function () {
1292
+ $(".fp-intent-menu").addClass("fp-hidden");
1293
+ });
1294
+
1295
+ // Provider management.
1296
+ content.find("#fp-provider-select").on("change", function () {
1297
+ switchProvider($(this).val());
1298
+ });
1299
+ content.find("#fp-add-provider").on("click", function () { addProvider(); });
1300
+ content.find("#fp-remove-provider").on("click", function () { removeProvider(); });
1301
+ content.find("#fp-test-provider").on("click", function () { testProvider(); });
1302
+ content.find("#fp-refresh-models").on("click", function () { refreshModels(); });
1303
+ content.find("#fp-reset-system-prompt").on("click", function () { resetSystemPrompt(); });
1304
+ content.find("#fp-persona-intensity").on("input", updatePersonaLabel);
1305
+
1306
+ // Re-enable Test provider live as the user types a model.
1307
+ content.find("#fp-model").on("input", function () {
1308
+ el("#fp-test-provider").prop("disabled", !($(this).val() || "").trim());
1309
+ });
1310
+
1311
+ content.find("#fp-save-settings").on("click", function () {
1312
+ saveSettings(null, true);
1313
+ });
1314
+ content.find("#fp-add-intent").on("click", function () {
1315
+ addCustomIntent();
1316
+ });
1317
+
1318
+ // Enter-to-send (Shift+Enter for newline) on the prompt box.
1319
+ content.find("#fp-prompt").on("keydown", function (e) {
1320
+ if (e.key === "Enter" && !e.shiftKey) {
1321
+ e.preventDefault();
1322
+ dispatchSend();
1323
+ }
1324
+ });
1325
+
1326
+ // /demo's "breathe" cue on Send is meant to draw the eye right
1327
+ // after the prompt is filled in — once the user starts editing
1328
+ // it themselves, the nudge has done its job.
1329
+ content.find("#fp-prompt").on("input", function () {
1330
+ el("#fp-send").removeClass("fp-send-breathe");
1331
+ });
1332
+
1333
+ bindPromptResize();
1334
+ bindSlashAutocomplete(el("#fp-prompt"));
1335
+
1336
+ // Live selection indicator. RED.events fires this whenever the
1337
+ // user changes what's selected on the canvas. While armed, a
1338
+ // new non-empty selection refreshes the pinned context (an empty
1339
+ // selection does NOT clear it — that's what lets follow-ups skip
1340
+ // reselection).
1341
+ RED.events.on("view:selection-changed", function () {
1342
+ if (armedExecuteAction) { pinCurrentSelection(); }
1343
+ updateSelectionStatus();
1344
+ });
1345
+
1346
+ // /build loop: "deploy" only fires on a SUCCESSFUL deploy (no
1347
+ // native "deploy failed" event exists — a failed deploy just
1348
+ // shows the user a notification, nothing programmatic), so a
1349
+ // failed deploy simply leaves the loop waiting at "apply"
1350
+ // rather than advancing — exactly what we want.
1351
+ RED.events.on("deploy", function () {
1352
+ if (activeBuildLoop && activeBuildLoop.waypoint === "apply") {
1353
+ activeBuildLoop.waypoint = "attach";
1354
+ renderLoopStepper(activeBuildLoop);
1355
+ // Start a timer so flows with no debug nodes (e.g. HTTP
1356
+ // endpoints) don't leave the loop stuck silently waiting.
1357
+ buildLoopNoDebugTimer = setTimeout(function () {
1358
+ buildLoopNoDebugTimer = null;
1359
+ if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
1360
+ addMessage("assistant",
1361
+ "No debug output detected yet. If this flow doesn't produce " +
1362
+ "automatic debug output (e.g. it's an HTTP endpoint), trigger " +
1363
+ "it and paste the response here — or describe what happened " +
1364
+ "and I'll review from that.");
1365
+ }, BUILD_LOOP_NO_DEBUG_TIMEOUT_MS);
1366
+ }
1367
+ });
1368
+
1369
+ // Pop-out: closing/refreshing the main editor also closes the
1370
+ // detached mirror, so it can never be left open and orphaned —
1371
+ // same as 21-debug.html's beforeunload handling.
1372
+ $(window).on("beforeunload", function () {
1373
+ if (popoutWindow) {
1374
+ try { popoutWindow.close(); } catch (e) { /* ignore */ }
1375
+ }
1376
+ });
1377
+
1378
+ // Pop-out child->parent intents: full Send dispatch
1379
+ // ("dispatchSend" — mode + prompt text, the pop-out's own
1380
+ // dispatchSend() relays here instead of calling
1381
+ // generate/document/modify/build/chat locally, since only the
1382
+ // main window has live RED.* context), "/compact"+"/expand"
1383
+ // ("runSlashCommand" — same reason), "import this already-
1384
+ // reviewed Generate/Document flow", "apply this already-
1385
+ // reviewed Modify diff", the /build loop's own apply/fix/stop
1386
+ // intents, and "clear the real conversation" (Clear Chat).
1387
+ // None of these run anything in the pop-out's own window — all
1388
+ // just ask the main window to do exactly what the equivalent
1389
+ // sidebar action would. Replies/confirmations reach the
1390
+ // pop-out via the existing #fp-messages relay, same as any
1391
+ // other new message.
1392
+ window.addEventListener("message", function (evt) {
1393
+ if (evt.origin !== location.origin) { return; }
1394
+ if (evt.source !== popoutWindow) { return; }
1395
+ var data = evt.data || {};
1396
+ if (data.event === "dispatchSend" && data.prompt) {
1397
+ el("#fp-prompt").val(data.prompt);
1398
+ if (data.mode === "generate") { generate(); }
1399
+ else if (data.mode === "build") { buildFlow(); }
1400
+ else if (data.mode === "document") { documentFlow(); }
1401
+ else if (data.mode === "modify") { modifyFlow(); }
1402
+ else { send("chat"); }
1403
+ } else if (data.event === "runSlashCommand" && data.command) {
1404
+ handleSlashCommand(data.command);
1405
+ } else if (data.event === "applyGenerated" && Array.isArray(data.flow)) {
1406
+ importGeneratedFlow(data.flow);
1407
+ } else if (data.event === "applyModify" && data.data) {
1408
+ var ad = data.data;
1409
+ var idMap = {};
1410
+ if (Array.isArray(ad.newNodes) && ad.newNodes.length) {
1411
+ idMap = applyInsertions(ad.newNodes, ad.newWires || [], ad.existingNodeIds || []) || {};
1412
+ }
1413
+ if (ad.hasMutations) {
1414
+ applyModifications(ad.nodeDiffs || [], ad.removeNodes || [], null, idMap);
1415
+ }
1416
+ if (Array.isArray(ad.newGroups) && ad.newGroups.length) {
1417
+ applyGroupChanges(ad.newGroups, idMap);
1418
+ }
1419
+ } else if (data.event === "applyBuild" && data.data && Array.isArray(data.data.flow)) {
1420
+ var bd = data.data;
1421
+ importGeneratedFlow(bd.flow, function (importResult) {
1422
+ startBuildLoop(bd.goal, bd.flow, importResult);
1423
+ });
1424
+ } else if (data.event === "applyBuildFix" && data.data) {
1425
+ var bf = data.data;
1426
+ var fixIdMap = {};
1427
+ if (Array.isArray(bf.newNodes) && bf.newNodes.length) {
1428
+ fixIdMap = applyInsertions(bf.newNodes, bf.newWires || [], bf.existingNodeIds || []) || {};
1429
+ }
1430
+ if (bf.hasMutations) {
1431
+ applyBuildLoopFix(bf.nodeDiffs || [], bf.removeNodes || [], fixIdMap, !!bf.capReached);
1432
+ }
1433
+ if (Array.isArray(bf.newGroups) && bf.newGroups.length) {
1434
+ applyGroupChanges(bf.newGroups, fixIdMap);
1435
+ }
1436
+ } else if (data.event === "stopBuildLoop") {
1437
+ stopBuildLoop("Build loop stopped — applied nodes remain as-is.");
1438
+ } else if (data.event === "clearChat") {
1439
+ clearChat();
1440
+ } else if (data.event === "requestDebugBuffer") {
1441
+ var snapshot = debugMessageBuffer.slice();
1442
+ var attachedIds = attachedDebugMessages.map(function (e) { return e.id; });
1443
+ try { popoutWindow.postMessage({ event: "debugBufferSnapshot", entries: snapshot, attachedIds: attachedIds }, location.origin); } catch (e) { /* ignore */ }
1444
+ } else if (data.event === "attachDebug" && data.entryId) {
1445
+ var found = null;
1446
+ for (var i = 0; i < debugMessageBuffer.length; i++) {
1447
+ if (debugMessageBuffer[i].id === data.entryId) { found = debugMessageBuffer[i]; break; }
1448
+ }
1449
+ if (found) {
1450
+ var alreadyIn = attachedDebugMessages.some(function (e) { return e.id === data.entryId; });
1451
+ if (!alreadyIn) { attachedDebugMessages.push(found); updateDebugStatus(); }
1452
+ }
1453
+ } else if (data.event === "useRecallItem") {
1454
+ if (data.user) { conversationHistory.push({ role: "user", content: String(data.user) }); }
1455
+ if (data.assistant) { conversationHistory.push({ role: "assistant", content: String(data.assistant) }); }
1456
+ updateSelectionStatus();
1457
+ } else if (data.event === "loadConversation" && data.id) {
1458
+ loadConversation(data.id);
1459
+ } else if (data.event === "requestRecallSearch" && data.query) {
1460
+ ajaxJson("POST", "flowpilot/recall", { query: data.query, conversationId: conversationId }, function (result) {
1461
+ try { popoutWindow.postMessage({ event: "recallResults", results: result.results || [] }, location.origin); } catch (e) { /* ignore */ }
1462
+ }, function (msg) {
1463
+ try { popoutWindow.postMessage({ event: "recallError", msg: msg }, location.origin); } catch (e) { /* ignore */ }
1464
+ });
1465
+ } else if (data.event === "requestConversationList") {
1466
+ ajaxJson("GET", "flowpilot/conversations", null, function (result) {
1467
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: result.conversations || [] }, location.origin); } catch (e) { /* ignore */ }
1468
+ }, function () {
1469
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: [] }, location.origin); } catch (e) { /* ignore */ }
1470
+ });
1471
+ } else if (data.event === "deleteConversation" && data.id) {
1472
+ ajaxJson("DELETE", "flowpilot/conversations/" + encodeURIComponent(data.id), null, function () {
1473
+ ajaxJson("GET", "flowpilot/conversations", null, function (result) {
1474
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: result.conversations || [] }, location.origin); } catch (e) { /* ignore */ }
1475
+ });
1476
+ });
1477
+ } else if (data.event === "deleteAllConversations") {
1478
+ ajaxJson("DELETE", "flowpilot/conversations", null, function () {
1479
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: [] }, location.origin); } catch (e) { /* ignore */ }
1480
+ });
1481
+ } else if (data.event === "clearAttachedDebug") {
1482
+ attachedDebugMessages = [];
1483
+ updateDebugStatus();
1484
+ } else if (data.event === "requestSettings") {
1485
+ ajaxJson("GET", "flowpilot/settings", null, function (result) {
1486
+ try { popoutWindow.postMessage({ event: "settingsLoaded", settings: result }, location.origin); } catch (e) { /* ignore */ }
1487
+ });
1488
+ }
1489
+ });
1490
+
1491
+ // Build intent buttons from built-in INTENTS plus any user-defined
1492
+ // customIntents (loaded from settings). Rebuilt after settings load
1493
+ // /save via renderIntents() so new custom buttons appear without a
1494
+ // reload.
1495
+ renderIntents(content.find("#fp-intents"));
1496
+
1497
+ RED.sidebar.addTab({
1498
+ id: "flowpilot",
1499
+ label: "FlowPilot",
1500
+ name: "FlowPilot",
1501
+ iconClass: "fa fa-paper-plane",
1502
+ content: content
1503
+ });
1504
+
1505
+ loadSettings();
1506
+ updateSelectionStatus();
1507
+ showChat();
1508
+ }
1509
+
1510
+ window.FlowPilotCore = { initMainWindow: initMainWindow, initPopout: initPopout };