@loupekit/sdk 0.10.15 → 0.10.16-next.61

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.
package/dist/index.js CHANGED
@@ -654,6 +654,99 @@ var STYLES = (
654
654
  font-size: 10px; white-space: pre-wrap; overflow-wrap: anywhere;
655
655
  }
656
656
 
657
+ /* ------------------------------------------------- consent-gated agent navigation */
658
+ .consent {
659
+ margin: 8px 10px 0; padding: 10px; border: 1px solid var(--pin); border-radius: 10px;
660
+ background: var(--bg-2);
661
+ }
662
+ .cs-head { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink); }
663
+ .cs-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pin); flex: none; animation: loupe-pulse 1.4s ease-in-out infinite; }
664
+ @media (prefers-reduced-motion: reduce) { .cs-dot { animation: none; } }
665
+ .cs-url {
666
+ margin-top: 6px; padding: 6px 7px; border-radius: 7px; background: var(--bg);
667
+ font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--ink);
668
+ overflow-wrap: anywhere;
669
+ }
670
+ .cs-why { margin-top: 6px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
671
+ .cs-btns { display: flex; justify-content: flex-end; gap: 6px; margin-top: 9px; }
672
+ .cs-btns button { padding: 5px 10px; border-radius: 7px; font-size: 11.5px; font-weight: 600; cursor: pointer; }
673
+ .cs-deny { border: 1px solid var(--line); background: var(--bg); color: var(--ink); }
674
+ .cs-deny:hover { border-color: var(--accent); }
675
+ .cs-go { border: 1px solid var(--accent); background: var(--accent); color: #fff; }
676
+
677
+ /* --------------------------------------------------------------- generate + iterate */
678
+ .genwrap { margin-top: 8px; }
679
+ .gen-open {
680
+ width: 100%; padding: 7px; border: 1px dashed var(--line); border-radius: 8px;
681
+ background: transparent; color: var(--muted); font-size: 11.5px; font-weight: 600; cursor: pointer;
682
+ }
683
+ .gen-open:hover { border-color: var(--accent); color: var(--accent); }
684
+ .genhead { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
685
+ .gen-t { flex: 1; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
686
+ .gen-nav { display: inline-flex; align-items: center; gap: 2px; }
687
+ .gen-step {
688
+ width: 20px; height: 20px; padding: 0; border: 1px solid var(--line); border-radius: 6px;
689
+ background: var(--bg); color: var(--ink); font-size: 13px; line-height: 1; cursor: pointer;
690
+ }
691
+ .gen-step:disabled { opacity: .4; cursor: default; }
692
+ .gen-n { min-width: 32px; text-align: center; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
693
+ .gen-undo, .gen-x {
694
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 7px;
695
+ background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
696
+ }
697
+ .gen-undo:disabled { opacity: .4; cursor: default; }
698
+ .gen-undo:hover, .gen-x:hover { border-color: var(--accent); }
699
+ .genbusy, .genempty { padding: 14px; text-align: center; font-size: 11.5px; color: var(--muted); }
700
+ /* the preview plane: generated markup over the original capture, sandboxed.
701
+ min-height matters \u2014 the iframe is absolutely positioned, so a thread with no
702
+ screenshot would otherwise give the plane nothing in-flow and collapse it to a
703
+ couple of pixels. */
704
+ .genplane { position: relative; min-height: 150px; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: var(--bg); }
705
+ .genbase { display: block; width: 100%; }
706
+ .genframe {
707
+ position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: transparent;
708
+ transition: opacity .08s linear;
709
+ }
710
+ .genslider { display: flex; align-items: center; gap: 7px; margin-top: 6px; }
711
+ .gs-lab { font-size: 10.5px; color: var(--muted); }
712
+ .gs-range { flex: 1; min-width: 0; accent-color: var(--accent); }
713
+ .gs-n { min-width: 32px; text-align: right; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
714
+ .gennotes {
715
+ margin-top: 6px; padding: 6px 8px; border-radius: 7px; background: var(--bg-3);
716
+ font-size: 11px; line-height: 1.45; color: var(--muted);
717
+ }
718
+ .geniter { display: flex; gap: 5px; margin-top: 7px; }
719
+ .iter-in {
720
+ flex: 1; min-width: 0; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px;
721
+ background: var(--bg); color: var(--ink); font-size: 11.5px;
722
+ }
723
+ .iter-in:focus { outline: none; border-color: var(--accent); }
724
+ .iter-send {
725
+ flex: none; padding: 6px 10px; border: 1px solid var(--accent); border-radius: 8px;
726
+ background: var(--accent); color: #fff; font-size: 11.5px; font-weight: 600; cursor: pointer;
727
+ }
728
+ .iter-send:disabled { opacity: .5; cursor: default; }
729
+ /* the access gate, when there is no generator */
730
+ .gengate { padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-2); }
731
+ .gate-t { font-size: 12px; font-weight: 700; color: var(--ink); }
732
+ .gate-b { margin-top: 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
733
+ .gate-ask {
734
+ margin-top: 8px; padding: 6px 10px; border: 1px solid var(--accent); border-radius: 8px;
735
+ background: var(--accent); color: #fff; font-size: 11.5px; font-weight: 600; cursor: pointer;
736
+ }
737
+ .gate-ask:disabled { opacity: .6; cursor: default; }
738
+ .gate-hint { margin-top: 7px; font-size: 10.5px; color: var(--muted); }
739
+ .gate-hint code { font-family: ui-monospace, Menlo, monospace; color: var(--ink); }
740
+
741
+ /* dictation */
742
+ .voice {
743
+ width: 30px; flex: none; padding: 0; border: 1px solid var(--line); border-radius: 8px;
744
+ background: var(--bg-2); color: var(--muted); font-size: 13px; cursor: pointer;
745
+ }
746
+ .voice:hover:not(:disabled) { border-color: var(--accent); color: var(--ink); }
747
+ .voice:disabled { opacity: .45; cursor: not-allowed; }
748
+ .voice.on { border-color: var(--pin); color: var(--pin); background: var(--bg-3); }
749
+
657
750
  /* ------------------------------------------------------- hint card (once per view) */
658
751
  .hint {
659
752
  position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
@@ -2378,7 +2471,7 @@ async function domToForeignObjectSvg(node, options) {
2378
2471
  log.time("embed node");
2379
2472
  embedNode(clone, context);
2380
2473
  const count = tasks.length;
2381
- let current = 0;
2474
+ let current2 = 0;
2382
2475
  const runTask = async () => {
2383
2476
  while (true) {
2384
2477
  const task = tasks.pop();
@@ -2389,10 +2482,10 @@ async function domToForeignObjectSvg(node, options) {
2389
2482
  } catch (error) {
2390
2483
  context.log.warn("Failed to run task", error);
2391
2484
  }
2392
- progress?.(++current, count);
2485
+ progress?.(++current2, count);
2393
2486
  }
2394
2487
  };
2395
- progress?.(current, count);
2488
+ progress?.(current2, count);
2396
2489
  await Promise.all([...Array.from({ length: 4 })].map(runTask));
2397
2490
  log.timeEnd("embed node");
2398
2491
  await onEmbedNode?.(clone);
@@ -2956,6 +3049,86 @@ function awaitingReview(comments) {
2956
3049
  return comments.filter((c) => c.status === "in_review");
2957
3050
  }
2958
3051
 
3052
+ // ../shared/dist/iteration.js
3053
+ function emptyIterations() {
3054
+ return { items: [], index: -1 };
3055
+ }
3056
+ function current(state) {
3057
+ return state.index >= 0 ? state.items[state.index] ?? null : null;
3058
+ }
3059
+ function addIteration(state, iteration) {
3060
+ const head = state.items.slice(0, state.index + 1);
3061
+ const items = [...head, iteration].slice(-MAX_ITERATIONS);
3062
+ return { items, index: items.length - 1 };
3063
+ }
3064
+ var MAX_ITERATIONS = 20;
3065
+ function canUndo(state) {
3066
+ return state.items.length > 0;
3067
+ }
3068
+ function undo(state) {
3069
+ if (!state.items.length)
3070
+ return state;
3071
+ const items = state.items.slice(0, -1);
3072
+ return { items, index: items.length - 1 };
3073
+ }
3074
+ function canMove(state, delta) {
3075
+ const next = state.index + delta;
3076
+ return next >= 0 && next < state.items.length;
3077
+ }
3078
+ function move(state, delta) {
3079
+ return canMove(state, delta) ? { ...state, index: state.index + delta } : state;
3080
+ }
3081
+ function stackLabel(state) {
3082
+ return state.items.length ? `${state.index + 1} / ${state.items.length}` : "";
3083
+ }
3084
+
3085
+ // ../shared/dist/consent.js
3086
+ function emptyConsent() {
3087
+ return { state: "idle", request: null, history: [] };
3088
+ }
3089
+ function isNavigableUrl(raw) {
3090
+ try {
3091
+ const u = new URL(raw);
3092
+ return u.protocol === "http:" || u.protocol === "https:";
3093
+ } catch {
3094
+ return false;
3095
+ }
3096
+ }
3097
+ function requestNavigation(record, request) {
3098
+ if (!isNavigableUrl(request.url))
3099
+ return record;
3100
+ return {
3101
+ state: "requested",
3102
+ request: {
3103
+ id: request.id ?? `nav${Date.now().toString(36)}`,
3104
+ at: request.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3105
+ url: request.url,
3106
+ reason: request.reason,
3107
+ requester: request.requester
3108
+ },
3109
+ history: record.history
3110
+ };
3111
+ }
3112
+ function isPending(record) {
3113
+ return record.state === "requested" && record.request !== null;
3114
+ }
3115
+ function decide(record, granted, now = (/* @__PURE__ */ new Date()).toISOString()) {
3116
+ if (!isPending(record))
3117
+ return { record, navigateTo: null };
3118
+ const url = record.request.url;
3119
+ return {
3120
+ record: {
3121
+ state: granted ? "granted" : "denied",
3122
+ request: null,
3123
+ history: [...record.history, { url, decision: granted ? "granted" : "denied", at: now }]
3124
+ },
3125
+ navigateTo: granted ? url : null
3126
+ };
3127
+ }
3128
+ function withdraw(record) {
3129
+ return isPending(record) ? { ...record, state: "idle", request: null } : record;
3130
+ }
3131
+
2959
3132
  // ../shared/dist/index.js
2960
3133
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
2961
3134
  var STAGE_LABELS = {
@@ -3003,7 +3176,7 @@ function normalizePriority(value) {
3003
3176
  }
3004
3177
 
3005
3178
  // src/app.ts
3006
- var SDK_VERSION = true ? "0.10.15" : "dev";
3179
+ var SDK_VERSION = true ? "0.10.16" : "dev";
3007
3180
  var ACCENTS = [
3008
3181
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3009
3182
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3159,6 +3332,18 @@ var LoupeApp = class {
3159
3332
  this.summaryOpen = false;
3160
3333
  /** Per-browser project settings: a repo override and the environment URLs. */
3161
3334
  this.project = { environments: [] };
3335
+ /** Iteration history per thread — generating a change is a stack, not a one-shot. */
3336
+ this.iterations = /* @__PURE__ */ new Map();
3337
+ /** The thread whose generate pane is open, if any. */
3338
+ this.genOpen = null;
3339
+ /** Threads with a generation in flight. */
3340
+ this.genBusy = /* @__PURE__ */ new Set();
3341
+ /** Opacity of the generated plane over the original, 0..1 (the compare slider). */
3342
+ this.genOpacity = 0.6;
3343
+ /** The navigation consent state machine — a URL only ever comes out of `decide`. */
3344
+ this.consent = emptyConsent();
3345
+ /** The live SpeechRecognition instance while dictating, if any. */
3346
+ this.voice = null;
3162
3347
  this.projSearch = "";
3163
3348
  this.projOpen = false;
3164
3349
  this.projResults = [];
@@ -3395,6 +3580,9 @@ var LoupeApp = class {
3395
3580
  window.removeEventListener("pointerup", this.onResizeUp);
3396
3581
  this.saveState();
3397
3582
  };
3583
+ /** Draft follow-ups, kept out of the render path so the input never loses focus. */
3584
+ this.iterDraft = /* @__PURE__ */ new Map();
3585
+ this.voiceTarget = null;
3398
3586
  this.cfg = cfg;
3399
3587
  this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
3400
3588
  }
@@ -3416,7 +3604,13 @@ var LoupeApp = class {
3416
3604
  this.observe();
3417
3605
  this.watchNavigation();
3418
3606
  if (!this.tourDone && this.open && !this.isMobile()) this.startTour();
3419
- if (this.cfg.autoOpen) this.setMode("inspect");
3607
+ if (this.cfg.autoOpen) this.setMode(this.cfg.tool === "note" ? "free" : "inspect");
3608
+ }
3609
+ /** Arm a tool from outside — the extension's context menus, or a host's own button. */
3610
+ openTool(tool) {
3611
+ this.open = true;
3612
+ this.applyDockLayout();
3613
+ this.setMode(tool === "note" ? "free" : "inspect");
3420
3614
  }
3421
3615
  /**
3422
3616
  * Reload comments when the page URL changes without a full reload (SPA
@@ -3649,7 +3843,9 @@ var LoupeApp = class {
3649
3843
  });
3650
3844
  const resize = el("div", "resize");
3651
3845
  resize.addEventListener("pointerdown", this.onResizeDown);
3652
- dock.append(head, this.minBar, tabs, homeView, commentsView, activityView, ...customViews, resize);
3846
+ this.consentEl = el("div", "consent");
3847
+ this.consentEl.style.display = "none";
3848
+ dock.append(head, this.minBar, this.consentEl, tabs, homeView, commentsView, activityView, ...customViews, resize);
3653
3849
  for (const [id, view] of [["home", homeView], ["comments", commentsView], ["activity", activityView]]) {
3654
3850
  this.viewEls.set(id, view);
3655
3851
  }
@@ -3964,7 +4160,7 @@ var LoupeApp = class {
3964
4160
  }
3965
4161
  pop.classList.add("open");
3966
4162
  const envs = this.project.environments;
3967
- pop.innerHTML = `<div class="pp-head"><span>Repository</span><button class="pp-x" data-role="pp-close" aria-label="Close">\u2715</button></div><div class="pp-cur">${repo ? `New comments are filed against <b>${escapeHtml(repo)}</b>.` : "This page is not linked to a repository yet."}</div>` + (this.cfg.repos ? `<input class="pp-search" type="search" placeholder="Search repositories\u2026" value="${escapeAttr(this.projSearch)}" aria-label="Search repositories"><div class="pp-list" id="loupe-pp-repos"></div>` : `<div class="pp-empty">No repository list to search. Pass <code>repos</code> to <code>init()</code> \u2014 a string array, or a function the panel calls with the search text.</div>`) + (repo ? `<button class="pp-clear" data-role="pp-clear">Unlink this page</button>` : "") + `<div class="pp-sep"></div><div class="pp-head"><span>Environments</span></div><div class="pp-list">` + (envs.length ? envs.map((u, i) => `<div class="pp-env"><span class="pp-env-u" title="${escapeAttr(u)}">${escapeHtml(u)}</span><button class="pp-x" data-env-rm="${i}" aria-label="Remove environment">\u2715</button></div>`).join("") : `<div class="pp-empty">No environment URLs yet.</div>`) + `</div><div class="pp-add"><input class="pp-env-url" type="url" placeholder="https://staging.example.com" value="${escapeAttr(this.envDraft)}" aria-label="Environment URL"><button class="pp-add-b" data-role="env-add">Add</button></div><div class="pp-err" id="loupe-pp-err"></div>`;
4163
+ pop.innerHTML = `<div class="pp-head"><span>Repository</span><button class="pp-x" data-role="pp-close" aria-label="Close">\u2715</button></div><div class="pp-cur">${repo ? `New comments are filed against <b>${escapeHtml(repo)}</b>.` : "This page is not linked to a repository yet."}</div>` + (this.cfg.repos ? `<input class="pp-search" type="search" placeholder="Search repositories\u2026" value="${escapeAttr(this.projSearch)}" aria-label="Search repositories"><div class="pp-list" id="loupe-pp-repos"></div>` : `<div class="pp-empty">No repository list to search. Pass <code>repos</code> to <code>init()</code> \u2014 a string array, or a function the panel calls with the search text.</div>`) + (repo ? `<button class="pp-clear" data-role="pp-clear">Unlink this page</button>` : "") + `<div class="pp-sep"></div><div class="pp-head"><span>Environments</span></div><div class="pp-list">` + (envs.length ? envs.map((u, i) => `<div class="pp-env"><span class="pp-env-u" title="${escapeAttr(u)}">${escapeHtml(u)}</span><button class="pp-x" data-env-rm="${i}" aria-label="Remove environment">\u2715</button></div>`).join("") : `<div class="pp-empty">No environment URLs yet.</div>`) + `</div><div class="pp-add"><input class="pp-env-url" type="url" placeholder="https://staging.example.com" value="${escapeAttr(this.envDraft)}" aria-label="Environment URL"><button class="pp-add-b" data-role="env-add">Add</button></div><div class="pp-sep"></div><div class="pp-head"><span>Local AI</span></div><div class="pp-empty">Any OpenAI-compatible server \u2014 used by the Generate pane.</div><div class="pp-add"><input class="pp-ai-url" type="url" placeholder="http://localhost:11434" value="${escapeAttr(this.project.localAi?.url ?? "")}" aria-label="Local AI endpoint"></div><div class="pp-add"><input class="pp-ai-model" type="text" placeholder="llama3.2" value="${escapeAttr(this.project.localAi?.model ?? "")}" aria-label="Model name"><button class="pp-add-b" data-role="ai-save">Save</button></div><div class="pp-add"><button class="pp-clear" data-role="ai-test">Test connection</button></div><div class="pp-ai-out" id="loupe-pp-ai"></div><div class="pp-err" id="loupe-pp-err"></div>`;
3968
4164
  this.renderRepoList();
3969
4165
  this.renderProjectError();
3970
4166
  pop.querySelector('[data-role="pp-close"]').onclick = () => this.setProjectOpen(false);
@@ -4020,6 +4216,25 @@ var LoupeApp = class {
4020
4216
  this.renderProject();
4021
4217
  pop.querySelector(".pp-env-url")?.focus();
4022
4218
  };
4219
+ for (const sel of [".pp-ai-url", ".pp-ai-model"]) {
4220
+ pop.querySelector(sel)?.addEventListener("click", (e) => e.stopPropagation());
4221
+ }
4222
+ pop.querySelector('[data-role="ai-save"]').onclick = () => {
4223
+ const url2 = normalizeEnvUrl(pop.querySelector(".pp-ai-url").value);
4224
+ const model = pop.querySelector(".pp-ai-model").value.trim();
4225
+ if (!url2 || !model) {
4226
+ this.setLocalAiStatus(this.project.localAi ? `Saved: ${this.project.localAi.model} at ${this.project.localAi.url}` : "Nothing saved yet.");
4227
+ this.projError = !url2 ? "Enter a full http:// or https:// endpoint URL." : "Enter a model name.";
4228
+ this.renderProjectError();
4229
+ return;
4230
+ }
4231
+ this.projError = "";
4232
+ this.setLocalAi({ url: url2, model });
4233
+ this.setLocalAiStatus(`Saved: ${model} at ${url2}`);
4234
+ };
4235
+ pop.querySelector('[data-role="ai-test"]').onclick = () => {
4236
+ void this.testLocalAi(pop);
4237
+ };
4023
4238
  }
4024
4239
  /** Per-browser project settings, keyed separately from the dock's UI state. */
4025
4240
  loadProject() {
@@ -4029,7 +4244,8 @@ var LoupeApp = class {
4029
4244
  const p = raw ? JSON.parse(raw) : null;
4030
4245
  const repo = typeof p?.repo === "string" && p.repo ? p.repo : void 0;
4031
4246
  const stored = Array.isArray(p?.environments) ? p.environments.filter((u) => typeof u === "string") : null;
4032
- this.project = { repo, environments: stored ?? fromConfig };
4247
+ const localAi = p?.localAi && typeof p.localAi.url === "string" && typeof p.localAi.model === "string" ? { url: p.localAi.url, model: p.localAi.model } : void 0;
4248
+ this.project = { repo, environments: stored ?? fromConfig, localAi };
4033
4249
  } catch {
4034
4250
  this.project = { environments: fromConfig };
4035
4251
  }
@@ -4463,7 +4679,7 @@ var LoupeApp = class {
4463
4679
  prioSel.value,
4464
4680
  typeSel.value
4465
4681
  );
4466
- btns.append(cancel, save);
4682
+ btns.append(this.voiceButton(ta), cancel, save);
4467
4683
  row.append(btns);
4468
4684
  c.append(label, title, ta, attach, meta, row);
4469
4685
  const w = 320, h = 380;
@@ -4910,6 +5126,7 @@ var LoupeApp = class {
4910
5126
  this.minBar.innerHTML = `<span class="logo">\u25CE</span><span class="mtext"><b>${openCount}</b> open ${this.scope === "all" ? "in this project" : "on this page"}</span><span class="mrestore" aria-hidden="true">\u25B8</span>`;
4911
5127
  for (const t of this.tabList) d.classList.toggle(`tab-${t.id}`, this.tab === t.id);
4912
5128
  for (const [id, view] of this.viewEls) view.classList.toggle("on", id === this.tab);
5129
+ this.renderConsent();
4913
5130
  this.renderHome();
4914
5131
  d.querySelectorAll(".tabs .tab").forEach((b) => b.classList.toggle("on", b.dataset.tab === this.tab));
4915
5132
  this.posMenu.querySelectorAll("[data-pos]").forEach((b) => b.classList.toggle("on", b.dataset.pos === this.dockMode));
@@ -4997,9 +5214,9 @@ var LoupeApp = class {
4997
5214
  if (!this.repoSel) return;
4998
5215
  const repos = [...new Set(this.visibleComments.map((c) => c.repo).filter((r) => !!r))].sort();
4999
5216
  this.repoSel.style.display = this.scope === "all" && repos.length > 1 ? "" : "none";
5000
- const current = this.repoFilter;
5217
+ const current2 = this.repoFilter;
5001
5218
  this.repoSel.innerHTML = `<option value="">All repos</option>` + repos.map((r) => `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`).join("");
5002
- this.repoSel.value = current;
5219
+ this.repoSel.value = current2;
5003
5220
  }
5004
5221
  itemView(c, i) {
5005
5222
  const detached = this.pins.get(c.id)?.classList.contains("detached") && !isResolved(c);
@@ -5110,6 +5327,7 @@ var LoupeApp = class {
5110
5327
  };
5111
5328
  actions.append(doneBtn, del);
5112
5329
  detail.appendChild(actions);
5330
+ detail.appendChild(this.generateView(c));
5113
5331
  item.appendChild(detail);
5114
5332
  item.onclick = () => {
5115
5333
  if (open) this.expanded.delete(c.id);
@@ -5199,6 +5417,381 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
5199
5417
  this.renderHome();
5200
5418
  };
5201
5419
  }
5420
+ // ---- generate + iterate ---------------------------------------------------
5421
+ /**
5422
+ * The generate pane for one thread: the preview plane, its opacity comparison
5423
+ * against the original capture, the iteration stack and the iterate input.
5424
+ *
5425
+ * The panel owns all of that; producing the markup is the host's `generate`
5426
+ * function. Without one there is nothing to preview, so the pane offers the
5427
+ * access gate instead of a dead button.
5428
+ */
5429
+ generateView(c) {
5430
+ const wrap = el("div", "genwrap");
5431
+ const state = this.iterations.get(c.id) ?? emptyIterations();
5432
+ const cur = current(state);
5433
+ const busy = this.genBusy.has(c.id);
5434
+ if (this.genOpen !== c.id) {
5435
+ const open = el("button", "gen-open", cur ? "\u2726 Change preview" : "\u2726 Generate a change");
5436
+ open.onclick = (e) => {
5437
+ e.stopPropagation();
5438
+ this.genOpen = c.id;
5439
+ this.renderList();
5440
+ };
5441
+ wrap.appendChild(open);
5442
+ return wrap;
5443
+ }
5444
+ if (!this.cfg.generate) {
5445
+ const gate = el("div", "gengate");
5446
+ gate.innerHTML = `<div class="gate-t">Generating needs access</div><div class="gate-b">This project has no generator configured for you yet. Ask for access and an owner can switch it on.</div>`;
5447
+ const ask = el("button", "gate-ask", "Request access to generate");
5448
+ ask.onclick = async (e) => {
5449
+ e.stopPropagation();
5450
+ ask.disabled = true;
5451
+ ask.textContent = "Request sent";
5452
+ await this.cfg.onRequestAccess?.({
5453
+ capability: "generate",
5454
+ user: this.cfg.user,
5455
+ projectKey: this.cfg.projectKey
5456
+ });
5457
+ this.addActivity({
5458
+ kind: "access.request",
5459
+ label: "Requested access to generate",
5460
+ detail: this.cfg.projectKey,
5461
+ level: "warn"
5462
+ });
5463
+ };
5464
+ gate.appendChild(ask);
5465
+ if (!this.cfg.onRequestAccess) {
5466
+ gate.appendChild(el("div", "gate-hint", "Pass onRequestAccess to init() to route this somewhere."));
5467
+ }
5468
+ wrap.appendChild(gate);
5469
+ wrap.appendChild(this.genClose(c));
5470
+ return wrap;
5471
+ }
5472
+ const head = el("div", "genhead");
5473
+ head.innerHTML = `<span class="gen-t">Generate</span>`;
5474
+ const nav = el("span", "gen-nav");
5475
+ const prev = el("button", "gen-step", "\u2039");
5476
+ prev.disabled = !canMove(state, -1);
5477
+ prev.setAttribute("aria-label", "Previous iteration");
5478
+ prev.onclick = (e) => {
5479
+ e.stopPropagation();
5480
+ this.setIterations(c.id, move(state, -1));
5481
+ };
5482
+ const label = el("span", "gen-n", stackLabel(state));
5483
+ const next = el("button", "gen-step", "\u203A");
5484
+ next.disabled = !canMove(state, 1);
5485
+ next.setAttribute("aria-label", "Next iteration");
5486
+ next.onclick = (e) => {
5487
+ e.stopPropagation();
5488
+ this.setIterations(c.id, move(state, 1));
5489
+ };
5490
+ nav.append(prev, label, next);
5491
+ head.appendChild(nav);
5492
+ const undo2 = el("button", "gen-undo", "Undo");
5493
+ undo2.disabled = !canUndo(state) || busy;
5494
+ undo2.onclick = (e) => {
5495
+ e.stopPropagation();
5496
+ this.setIterations(c.id, undo(state));
5497
+ this.addActivity({ kind: "generate.undo", label: "Undid a generated change" });
5498
+ };
5499
+ head.append(undo2, this.genClose(c));
5500
+ wrap.appendChild(head);
5501
+ if (busy) {
5502
+ wrap.appendChild(el("div", "genbusy", "Generating\u2026"));
5503
+ } else if (cur) {
5504
+ const plane = el("div", "genplane");
5505
+ if (c.screenshot) {
5506
+ const base = el("img", "genbase");
5507
+ base.src = c.screenshot;
5508
+ base.alt = "Original capture";
5509
+ plane.appendChild(base);
5510
+ }
5511
+ const frame = el("iframe", "genframe");
5512
+ frame.setAttribute("sandbox", "");
5513
+ frame.setAttribute("title", "Generated change preview");
5514
+ frame.srcdoc = `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;padding:10px;background:#fff;font:13px -apple-system,system-ui,sans-serif;color:#111}${cur.css ?? ""}</style>${cur.html}`;
5515
+ frame.style.opacity = String(this.genOpacity);
5516
+ plane.appendChild(frame);
5517
+ wrap.appendChild(plane);
5518
+ const slider = el("div", "genslider");
5519
+ slider.innerHTML = `<span class="gs-lab">Compare</span>`;
5520
+ const range = el("input", "gs-range");
5521
+ range.type = "range";
5522
+ range.min = "0";
5523
+ range.max = "100";
5524
+ range.value = String(Math.round(this.genOpacity * 100));
5525
+ range.setAttribute("aria-label", "Generated change opacity");
5526
+ range.oninput = () => {
5527
+ this.genOpacity = Number(range.value) / 100;
5528
+ frame.style.opacity = String(this.genOpacity);
5529
+ };
5530
+ slider.append(range, el("span", "gs-n", `${Math.round(this.genOpacity * 100)}%`));
5531
+ wrap.appendChild(slider);
5532
+ if (cur.notes) wrap.appendChild(el("div", "gennotes", cur.notes));
5533
+ } else {
5534
+ wrap.appendChild(el("div", "genempty", "Nothing generated yet."));
5535
+ }
5536
+ const iter = el("div", "geniter");
5537
+ const kind = document.createElement("select");
5538
+ kind.className = "mini";
5539
+ kind.title = "How to treat your follow-up";
5540
+ kind.setAttribute("aria-label", "Iteration kind");
5541
+ kind.append(
5542
+ optionEl("Refine", "refine", true),
5543
+ optionEl("Revise", "revise", false)
5544
+ );
5545
+ const input = el("input", "iter-in");
5546
+ input.type = "text";
5547
+ input.placeholder = cur ? "Refine it, e.g. \u201Clarger button\u201D" : "What should change?";
5548
+ input.value = this.iterDraft.get(c.id) ?? "";
5549
+ input.oninput = () => this.iterDraft.set(c.id, input.value);
5550
+ for (const n of [kind, input]) n.addEventListener("click", (e) => e.stopPropagation());
5551
+ const send = el("button", "iter-send", "Send");
5552
+ send.disabled = busy;
5553
+ send.onclick = (e) => {
5554
+ e.stopPropagation();
5555
+ const prompt = (this.iterDraft.get(c.id) ?? "").trim();
5556
+ if (!prompt) return;
5557
+ this.iterDraft.delete(c.id);
5558
+ void this.runGenerate(c, prompt, kind.value);
5559
+ };
5560
+ input.onkeydown = (e) => {
5561
+ if (e.key === "Enter") send.click();
5562
+ };
5563
+ iter.append(kind, input, send);
5564
+ wrap.appendChild(iter);
5565
+ return wrap;
5566
+ }
5567
+ genClose(c) {
5568
+ const x = el("button", "gen-x", "\u2715");
5569
+ x.setAttribute("aria-label", "Close");
5570
+ x.onclick = (e) => {
5571
+ e.stopPropagation();
5572
+ this.genOpen = null;
5573
+ this.renderList();
5574
+ };
5575
+ return x;
5576
+ }
5577
+ setIterations(id, state) {
5578
+ this.iterations.set(id, state);
5579
+ this.renderList();
5580
+ }
5581
+ /** Ask the host's generator for a change, and stack the result. */
5582
+ async runGenerate(c, prompt, kind) {
5583
+ const generate = this.cfg.generate;
5584
+ if (!generate) return;
5585
+ const state = this.iterations.get(c.id) ?? emptyIterations();
5586
+ this.genBusy.add(c.id);
5587
+ this.genOpen = c.id;
5588
+ this.renderList();
5589
+ const started = Date.now();
5590
+ try {
5591
+ const out = await generate({
5592
+ comment: c,
5593
+ prompt,
5594
+ kind,
5595
+ previous: current(state) ?? void 0,
5596
+ localAi: this.project.localAi
5597
+ });
5598
+ const next = addIteration(state, {
5599
+ id: `it${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
5600
+ at: (/* @__PURE__ */ new Date()).toISOString(),
5601
+ html: out.html,
5602
+ css: out.css,
5603
+ notes: out.notes,
5604
+ prompt,
5605
+ kind
5606
+ });
5607
+ this.genBusy.delete(c.id);
5608
+ this.iterations.set(c.id, next);
5609
+ this.addActivity({
5610
+ kind: `generate.${kind}`,
5611
+ label: `${kind === "generate" ? "Generated" : "Iterated on"} \u201C${c.title || prompt}\u201D`,
5612
+ detail: `${Math.round((Date.now() - started) / 1e3)}s \xB7 ${stackLabel(next)}`
5613
+ });
5614
+ } catch (e) {
5615
+ this.genBusy.delete(c.id);
5616
+ this.addActivity({
5617
+ kind: "generate.error",
5618
+ label: `Generation failed: ${e instanceof Error ? e.message : String(e)}`,
5619
+ level: "error"
5620
+ });
5621
+ }
5622
+ this.renderList();
5623
+ }
5624
+ // ---- agent navigation (consent-gated) -------------------------------------
5625
+ /**
5626
+ * An agent asks to move the browser. Nothing navigates here — the request is
5627
+ * queued and the user answers it. `decide()` is the only thing that ever yields a
5628
+ * URL, and it yields one only on an explicit grant.
5629
+ */
5630
+ requestNavigation(url, opts = {}) {
5631
+ const next = requestNavigation(this.consent, { url, ...opts });
5632
+ if (next === this.consent) return;
5633
+ this.consent = next;
5634
+ this.open = true;
5635
+ this.saveState();
5636
+ this.applyDockLayout();
5637
+ this.renderConsent();
5638
+ this.addActivity({
5639
+ kind: "nav.request",
5640
+ label: `Asked to open ${url}`,
5641
+ detail: opts.requester,
5642
+ level: "warn"
5643
+ });
5644
+ }
5645
+ /** Local-AI settings, used as the default in GenerateRequest.localAi. */
5646
+ setLocalAi(config) {
5647
+ this.project = { ...this.project, localAi: config ?? void 0 };
5648
+ this.saveProject();
5649
+ this.renderProject();
5650
+ }
5651
+ setLocalAiStatus(text) {
5652
+ const box = this.homeEl?.querySelector("#loupe-pp-ai");
5653
+ if (box) box.textContent = text;
5654
+ }
5655
+ /**
5656
+ * Ask the configured endpoint what it serves. A real check against the real
5657
+ * server — an OpenAI-compatible `/v1/models` is what Ollama, llama.cpp and the
5658
+ * rest all expose — with a timeout, so a wrong port reports rather than hangs.
5659
+ */
5660
+ async testLocalAi(pop) {
5661
+ const url = normalizeEnvUrl(pop.querySelector(".pp-ai-url").value);
5662
+ const model = pop.querySelector(".pp-ai-model").value.trim();
5663
+ if (!url) {
5664
+ this.projError = "Enter a full http:// or https:// endpoint URL.";
5665
+ this.renderProjectError();
5666
+ return;
5667
+ }
5668
+ this.projError = "";
5669
+ this.setLocalAiStatus("Checking\u2026");
5670
+ const ctl = new AbortController();
5671
+ const timer = setTimeout(() => ctl.abort(), 4e3);
5672
+ try {
5673
+ const res = await fetch(`${url}/v1/models`, { signal: ctl.signal });
5674
+ if (!res.ok) {
5675
+ this.setLocalAiStatus(`Reachable, but it answered ${res.status}.`);
5676
+ return;
5677
+ }
5678
+ const body = await res.json().catch(() => null);
5679
+ const ids = Array.isArray(body?.data) ? body.data.map((m) => String(m?.id ?? "")).filter(Boolean) : [];
5680
+ if (model && ids.length && !ids.some((i) => i === model || i.startsWith(`${model}:`))) {
5681
+ const shown = ids.slice(0, 4).join(", ");
5682
+ this.setLocalAiStatus(`Connected, but no \u201C${model}\u201D \u2014 it serves: ${shown}${ids.length > 4 ? ", \u2026" : ""}`);
5683
+ return;
5684
+ }
5685
+ this.setLocalAiStatus(
5686
+ ids.length ? `Connected \u2014 ${ids.length} model${ids.length === 1 ? "" : "s"} available.` : "Connected."
5687
+ );
5688
+ } catch (e) {
5689
+ this.setLocalAiStatus(e?.name === "AbortError" ? "Timed out. Is the server running, and does it allow this origin (CORS)?" : "Could not reach it. Check the URL, and that the server allows this origin (CORS).");
5690
+ } finally {
5691
+ clearTimeout(timer);
5692
+ }
5693
+ }
5694
+ renderConsent() {
5695
+ if (!this.consentEl) return;
5696
+ if (!isPending(this.consent)) {
5697
+ this.consentEl.innerHTML = "";
5698
+ this.consentEl.style.display = "none";
5699
+ return;
5700
+ }
5701
+ const r = this.consent.request;
5702
+ this.consentEl.style.display = "";
5703
+ this.consentEl.innerHTML = `<div class="cs-head"><span class="cs-dot"></span><b>${escapeHtml(r.requester ?? "An agent")}</b> wants to open a page</div><div class="cs-url">${escapeHtml(r.url)}</div>` + (r.reason ? `<div class="cs-why">${escapeHtml(r.reason)}</div>` : "") + `<div class="cs-btns"><button class="cs-deny">Stay here</button><button class="cs-go">Go there</button></div>`;
5704
+ this.consentEl.querySelector(".cs-deny").onclick = () => {
5705
+ const { record } = decide(this.consent, false);
5706
+ this.consent = record;
5707
+ this.renderConsent();
5708
+ this.addActivity({ kind: "nav.deny", label: `Declined opening ${r.url}` });
5709
+ };
5710
+ this.consentEl.querySelector(".cs-go").onclick = () => {
5711
+ const { record, navigateTo } = decide(this.consent, true);
5712
+ this.consent = record;
5713
+ this.renderConsent();
5714
+ this.addActivity({ kind: "nav.grant", label: `Opened ${navigateTo}` });
5715
+ if (navigateTo) window.location.assign(navigateTo);
5716
+ };
5717
+ }
5718
+ /** Drop a pending request without deciding it — e.g. the panel is closing. */
5719
+ abandonNavigation() {
5720
+ this.consent = withdraw(this.consent);
5721
+ this.renderConsent();
5722
+ }
5723
+ /** The dictation button, or nothing at all where the browser has no speech API. */
5724
+ voiceButton(target) {
5725
+ const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
5726
+ const b = el("button", "voice", "");
5727
+ this.voiceEl = b;
5728
+ if (!Ctor) {
5729
+ b.textContent = "\u{1F3A4}";
5730
+ b.disabled = true;
5731
+ b.title = "Dictation is not available in this browser";
5732
+ b.setAttribute("aria-label", "Dictation unavailable");
5733
+ return b;
5734
+ }
5735
+ const idle = () => {
5736
+ b.classList.remove("on");
5737
+ b.textContent = "\u{1F3A4}";
5738
+ b.setAttribute("aria-label", "Dictate");
5739
+ b.title = "Dictate";
5740
+ };
5741
+ const listening = () => {
5742
+ b.classList.add("on");
5743
+ b.textContent = "\u23FA";
5744
+ b.setAttribute("aria-label", "Stop dictating");
5745
+ b.title = "Listening \u2014 click to stop";
5746
+ };
5747
+ idle();
5748
+ b.onclick = (e) => {
5749
+ e.stopPropagation();
5750
+ if (this.voice) {
5751
+ this.stopVoice();
5752
+ idle();
5753
+ return;
5754
+ }
5755
+ const rec = new Ctor();
5756
+ rec.continuous = true;
5757
+ rec.interimResults = true;
5758
+ rec.lang = document.documentElement.lang || "en-US";
5759
+ let base = target.value ? `${target.value} ` : "";
5760
+ rec.onresult = (ev) => {
5761
+ let text = "";
5762
+ for (let i = ev.resultIndex; i < ev.results.length; i++) text += ev.results[i][0].transcript;
5763
+ target.value = (base + text).replace(/\s+/g, " ").trimStart();
5764
+ target.dispatchEvent(new Event("input", { bubbles: true }));
5765
+ };
5766
+ rec.onerror = (ev) => {
5767
+ this.stopVoice();
5768
+ idle();
5769
+ this.addActivity({ kind: "voice.error", label: `Dictation failed: ${ev?.error ?? "unknown"}`, level: "error" });
5770
+ };
5771
+ rec.onend = () => {
5772
+ this.voice = null;
5773
+ idle();
5774
+ };
5775
+ this.voice = rec;
5776
+ this.voiceTarget = target;
5777
+ try {
5778
+ rec.start();
5779
+ listening();
5780
+ } catch {
5781
+ this.voice = null;
5782
+ idle();
5783
+ }
5784
+ };
5785
+ return b;
5786
+ }
5787
+ stopVoice() {
5788
+ try {
5789
+ this.voice?.stop();
5790
+ } catch {
5791
+ }
5792
+ this.voice = null;
5793
+ this.voiceTarget = null;
5794
+ }
5202
5795
  flash(id) {
5203
5796
  const c = this.comments.find((x) => x.id === id);
5204
5797
  const pin = this.pins.get(id);
@@ -5474,12 +6067,24 @@ function setActivityStatus(status) {
5474
6067
  function clearActivity() {
5475
6068
  app?.clearActivity();
5476
6069
  }
6070
+ function requestNavigation2(url, opts) {
6071
+ app?.requestNavigation(url, opts);
6072
+ }
6073
+ function setLocalAi(config) {
6074
+ app?.setLocalAi(config);
6075
+ }
6076
+ function openTool(tool) {
6077
+ app?.openTool(tool);
6078
+ }
5477
6079
  export {
5478
6080
  clearActivity,
5479
6081
  connectTab,
5480
6082
  destroy,
5481
6083
  init,
6084
+ openTool,
6085
+ requestNavigation2 as requestNavigation,
5482
6086
  setActivityStatus,
6087
+ setLocalAi,
5483
6088
  trackActivity
5484
6089
  };
5485
6090
  //# sourceMappingURL=index.js.map