opencode-browser-annotation-plugin 0.3.2 → 0.4.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.
@@ -43,8 +43,22 @@ chrome.action.onClicked.addListener((tab) => {
43
43
  void sendToOverlay(tab, "oc-toggle-sidebar");
44
44
  });
45
45
 
46
- chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
46
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
47
47
  (async () => {
48
+ if (msg?.type === "oc-capture") {
49
+ // Capture the visible tab for the extension's own thumbnail preview only.
50
+ // This image is never sent to the plugin/agent; the content script crops
51
+ // it to the element and keeps it local to the sidebar UI.
52
+ try {
53
+ const windowId = sender?.tab?.windowId;
54
+ const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
55
+ sendResponse({ ok: true, dataUrl });
56
+ } catch (error) {
57
+ sendResponse({ ok: false, error: error?.message || "capture failed" });
58
+ }
59
+ return;
60
+ }
61
+
48
62
  if (msg?.type === "oc-status") {
49
63
  try {
50
64
  const endpoint = await getEndpoint();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.3.2",
4
+ "version": "0.4.0",
5
5
  "description": "Alt+A to annotate an element, Alt+Shift+A for the annotation list. Sends element metadata to your OpenCode agent.",
6
6
  "permissions": [
7
7
  "activeTab",
@@ -26,6 +26,8 @@
26
26
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
27
27
  logo:
28
28
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0H5a2 2 0 0 1-2-2v-4m6 6h10a2 2 0 0 0 2-2v-4"/></svg>',
29
+ caret:
30
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
29
31
  };
30
32
 
31
33
  const STYLE = `
@@ -58,6 +60,8 @@
58
60
  .iconbtn { display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; color: #8b90a0; cursor: pointer; width: 28px; height: 28px; border-radius: 8px; padding: 0; }
59
61
  .iconbtn svg { width: 15px; height: 15px; }
60
62
  .iconbtn:hover { background: rgba(255,255,255,.08); color: #e6e8ee; }
63
+ .iconbtn.active { background: #3f7dff; color: #fff; box-shadow: 0 0 0 3px rgba(63,125,255,.28); }
64
+ .iconbtn.active:hover { background: #3670f0; color: #fff; }
61
65
 
62
66
  textarea.oc-ta {
63
67
  width: 100%; resize: vertical; min-height: 62px; font: inherit;
@@ -90,20 +94,34 @@
90
94
  .oc-list::-webkit-scrollbar { width: 10px; }
91
95
  .oc-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
92
96
  .oc-empty { color: #71768a; font-size: 12.5px; padding: 24px 8px; text-align: center; line-height: 1.6; }
93
- .oc-card { background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.08); border-radius: 11px; padding: 9px 10px; }
94
- .oc-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
95
- .oc-desc { font: 11px ui-monospace, monospace; color: #aeb4c6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(255,255,255,.06); padding: 2px 7px; border-radius: 6px; flex: 1; }
97
+ .oc-card { position: relative; background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.08); border-radius: 11px; padding: 9px 10px; }
98
+ .oc-card .oc-rm { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px; }
99
+ .oc-card .oc-rm svg { width: 13px; height: 13px; }
100
+ .oc-thumb { width: 100%; height: 96px; object-fit: cover; object-position: top left; background: #0e0f14; border: 1px solid rgba(255,255,255,.08); border-radius: 8px; display: block; margin-bottom: 8px; }
101
+ .oc-desc { font: 11px ui-monospace, monospace; color: #aeb4c6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(255,255,255,.06); padding: 2px 7px; border-radius: 6px; display: inline-block; max-width: calc(100% - 30px); margin-bottom: 6px; }
96
102
  .oc-card .oc-text { font-size: 12.5px; color: #d4d7e0; white-space: pre-wrap; word-break: break-word; }
97
- .oc-foot-bar { padding: 11px 12px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
98
- .oc-status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; }
99
- .oc-select { flex: none; max-width: 150px; font: inherit; font-size: 11.5px; color: #cfd3df; background: #14151b; border: 1px solid rgba(255,255,255,.12); border-radius: 7px; padding: 4px 6px; }
100
- .oc-select:focus { outline: none; border-color: #4c8dff; }
101
- .oc-status { flex: 1; min-width: 0; font-size: 12px; display: flex; align-items: center; gap: 8px; color: #9298aa; }
102
- .oc-status span, .oc-status { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
103
+ .oc-foot-bar { padding: 12px; border-top: 1px solid rgba(255,255,255,.07); position: relative; }
104
+ .oc-status-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
105
+ .oc-status { flex: 1; min-width: 0; font-size: 12px; display: flex; align-items: center; gap: 8px; color: #9298aa; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
103
106
  .oc-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #5b6070; flex: none; }
104
107
  .oc-status.good::before { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.6); }
105
108
  .oc-status.warn::before { background: #fbbf24; } .oc-status.bad::before { background: #f87171; } .oc-status.checking::before { background: #60a5fa; }
106
- .oc-status.good { color: #34d399; } .oc-status.warn { color: #fbbf24; } .oc-status.bad { color: #f87171; }
109
+ .oc-status.good { color: #34d399; } .oc-status.warn { color: #fbbf24; } .oc-status.bad { color: #f87171; } .oc-status.checking { color: #93b4ff; }
110
+
111
+ /* custom session dropdown (opens upward) */
112
+ .oc-dd { position: relative; flex: none; width: 176px; }
113
+ .oc-dd-btn { width: 100%; display: flex; align-items: center; gap: 6px; font: inherit; font-size: 11.5px; color: #cfd3df; background: #14151b; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; padding: 6px 8px; cursor: pointer; }
114
+ .oc-dd-btn:hover { border-color: rgba(255,255,255,.22); }
115
+ .oc-dd-btn .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
116
+ .oc-dd-btn .car { width: 12px; height: 12px; flex: none; transition: transform .15s; }
117
+ .oc-dd.open .oc-dd-btn .car { transform: rotate(180deg); }
118
+ .oc-dd-menu { position: absolute; bottom: calc(100% + 6px); right: 0; left: 0; max-height: 240px; overflow-y: auto; background: #1b1d25; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; box-shadow: 0 -12px 34px rgba(0,0,0,.5); padding: 5px; display: none; }
119
+ .oc-dd.open .oc-dd-menu { display: block; }
120
+ .oc-dd-opt { font-size: 12px; color: #cfd3df; padding: 7px 9px; border-radius: 7px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
121
+ .oc-dd-opt:hover { background: rgba(255,255,255,.08); }
122
+ .oc-dd-opt.sel { background: rgba(76,141,255,.16); color: #7aa9ff; }
123
+ .oc-dd-menu::-webkit-scrollbar { width: 8px; }
124
+ .oc-dd-menu::-webkit-scrollbar-thumb { background: rgba(255,255,255,.14); border-radius: 6px; }
107
125
  .oc-submit { width: 100%; }
108
126
  .oc-toast { position: absolute; left: 12px; right: 12px; bottom: 58px; background: #0e0f14; color: #fff; font-size: 12.5px; padding: 9px 12px; border-radius: 9px; opacity: 0; transform: translateY(6px); transition: opacity .2s, transform .2s; pointer-events: none; box-shadow: 0 10px 30px rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.08); }
109
127
  .oc-toast.show { opacity: 1; transform: translateY(0); }
@@ -244,6 +262,44 @@
244
262
  return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
245
263
  }
246
264
 
265
+ // Capture a thumbnail of the selected element for the SIDEBAR LIST ONLY.
266
+ // Never sent to the plugin/agent (stripped before submit). Best-effort: on any
267
+ // failure we simply omit the thumbnail.
268
+ function captureThumb(rect) {
269
+ return new Promise((resolve) => {
270
+ sendMsg({ type: "oc-capture" }, (res) => {
271
+ if (!res || !res.ok || !res.dataUrl) return resolve(null);
272
+ const img = new Image();
273
+ img.onload = () => {
274
+ try {
275
+ const dpr = window.devicePixelRatio || 1;
276
+ const pad = 6 * dpr;
277
+ let sx = Math.max(0, rect.left * dpr - pad);
278
+ let sy = Math.max(0, rect.top * dpr - pad);
279
+ let sw = Math.min(img.width - sx, rect.width * dpr + pad * 2);
280
+ let sh = Math.min(img.height - sy, rect.height * dpr + pad * 2);
281
+ if (sw <= 0 || sh <= 0) return resolve(null);
282
+ // cap output size for a compact list thumbnail
283
+ const maxW = 300;
284
+ const scale = Math.min(1, maxW / sw);
285
+ const cw = Math.round(sw * scale);
286
+ const ch = Math.round(sh * scale);
287
+ const c = document.createElement("canvas");
288
+ c.width = cw;
289
+ c.height = ch;
290
+ const ctx = c.getContext("2d");
291
+ ctx.drawImage(img, sx, sy, sw, sh, 0, 0, cw, ch);
292
+ resolve(c.toDataURL("image/png"));
293
+ } catch {
294
+ resolve(null);
295
+ }
296
+ };
297
+ img.onerror = () => resolve(null);
298
+ img.src = res.dataUrl;
299
+ });
300
+ });
301
+ }
302
+
247
303
  // ---------- picking ----------
248
304
 
249
305
  function pickTarget(e) {
@@ -282,8 +338,13 @@
282
338
  e.stopPropagation();
283
339
  e.stopImmediatePropagation();
284
340
  const rect = hit.el.getBoundingClientRect();
341
+ const meta = elementMeta(hit.el, hit.inShadow);
285
342
  stopPicking();
286
- openPopup(elementMeta(hit.el, hit.inShadow), rect);
343
+ // Capture the thumbnail while the element is still unobscured (before the
344
+ // popup is drawn), then open the popup. Best-effort; thumb may be null.
345
+ requestAnimationFrame(() => {
346
+ captureThumb(rect).then((thumb) => openPopup(meta, rect, thumb));
347
+ });
287
348
  }
288
349
 
289
350
  function onKey(e) {
@@ -330,12 +391,18 @@
330
391
  }
331
392
  }
332
393
 
394
+ function setPickActive(on) {
395
+ const btn = root && root.getElementById("oc-pick");
396
+ if (btn) btn.classList.toggle("active", on);
397
+ }
398
+
333
399
  function startPicking() {
334
400
  ensureUI();
335
401
  closePopup();
336
402
  picking = true;
337
403
  document.documentElement.style.cursor = "crosshair";
338
404
  root.getElementById("oc-hint").style.display = "flex";
405
+ setPickActive(true);
339
406
  }
340
407
 
341
408
  function stopPicking() {
@@ -344,6 +411,7 @@
344
411
  if (root) {
345
412
  hl().style.display = "none";
346
413
  root.getElementById("oc-hint").style.display = "none";
414
+ setPickActive(false);
347
415
  }
348
416
  }
349
417
 
@@ -356,7 +424,7 @@
356
424
  }
357
425
  }
358
426
 
359
- function openPopup(element, rect) {
427
+ function openPopup(element, rect, thumb) {
360
428
  ensureUI();
361
429
  closePopup();
362
430
  const el = document.createElement("div");
@@ -383,7 +451,7 @@
383
451
  setTimeout(() => ta.focus(), 30);
384
452
 
385
453
  el.querySelector(".oc-x").addEventListener("click", closePopup);
386
- const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element });
454
+ const build = () => ({ instruction: ta.value.trim(), page: { url: location.href, title: document.title }, element, thumb });
387
455
  el.querySelector(".oc-add").addEventListener("click", () => {
388
456
  if (!ta.value.trim()) return ta.focus();
389
457
  pending.push(build());
@@ -444,7 +512,13 @@
444
512
  <div class="oc-foot-bar">
445
513
  <div class="oc-status-row">
446
514
  <div class="oc-status" id="oc-status">…</div>
447
- <select class="oc-select" id="oc-session" title="Target session"></select>
515
+ <div class="oc-dd" id="oc-dd">
516
+ <button class="oc-dd-btn" id="oc-dd-btn" title="Target session">
517
+ <span class="lbl" id="oc-dd-lbl">Auto (last active)</span>
518
+ <span class="car">${ICON.caret}</span>
519
+ </button>
520
+ <div class="oc-dd-menu" id="oc-dd-menu"></div>
521
+ </div>
448
522
  </div>
449
523
  <button class="btn primary oc-submit" id="oc-submit" disabled>${ICON.send}<span>Submit to agent</span></button>
450
524
  <div class="oc-toast" id="oc-toast"></div>
@@ -452,8 +526,13 @@
452
526
  root.appendChild(sb);
453
527
  sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
454
528
  sb.querySelector("#oc-pick").addEventListener("click", () => startPicking());
455
- sb.querySelector("#oc-session").addEventListener("change", (e) => {
456
- targetSessionID = e.target.value || null;
529
+ sb.querySelector("#oc-dd-btn").addEventListener("click", (e) => {
530
+ e.stopPropagation();
531
+ sb.querySelector("#oc-dd").classList.toggle("open");
532
+ });
533
+ root.addEventListener("click", (e) => {
534
+ const dd = sb.querySelector("#oc-dd");
535
+ if (dd && !dd.contains(e.target)) dd.classList.remove("open");
457
536
  });
458
537
  sb.querySelector("#oc-submit").addEventListener("click", () => {
459
538
  submit(pending, false);
@@ -502,11 +581,11 @@
502
581
  pending.forEach((a, i) => {
503
582
  const card = document.createElement("div");
504
583
  card.className = "oc-card";
584
+ const thumb = a.thumb ? `<img class="oc-thumb" src="${a.thumb}" alt="">` : "";
505
585
  card.innerHTML = `
506
- <div class="oc-card-head">
507
- <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
508
- <button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
509
- </div>
586
+ <button class="iconbtn oc-rm" data-i="${i}" title="Remove">${ICON.trash}</button>
587
+ ${thumb}
588
+ <span class="oc-desc" title="${escapeHtml(a.element.selector || "")}">${escapeHtml(descriptor(a.element))}</span>
510
589
  <div class="oc-text">${escapeHtml(a.instruction || "(no instruction)")}</div>`;
511
590
  list.appendChild(card);
512
591
  });
@@ -535,13 +614,8 @@
535
614
  sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
536
615
  autoSessionID = res.data.sessionID || null;
537
616
  renderSessions();
538
- if (sessions.length || res.data.activeSession) {
539
- el.className = "oc-status good";
540
- el.textContent = "Connected";
541
- } else {
542
- el.className = "oc-status warn";
543
- el.textContent = "Connected — send a message in OpenCode first";
544
- }
617
+ el.className = "oc-status good";
618
+ el.textContent = "Connected";
545
619
  } else {
546
620
  el.className = "oc-status bad";
547
621
  el.textContent = "Not connected — check the SSH tunnel";
@@ -551,22 +625,33 @@
551
625
 
552
626
  function renderSessions() {
553
627
  if (!root) return;
554
- const sel = root.getElementById("oc-session");
555
- if (!sel) return;
628
+ const menu = root.getElementById("oc-dd-menu");
629
+ const lbl = root.getElementById("oc-dd-lbl");
630
+ if (!menu || !lbl) return;
556
631
  // If the chosen target vanished, fall back to auto.
557
632
  if (targetSessionID && !sessions.some((s) => s.id === targetSessionID)) targetSessionID = null;
558
633
  const current = effectiveTarget();
559
- const opts = [`<option value="">Auto (last active)</option>`].concat(
560
- sessions.map((s) => {
561
- const active = s.id === autoSessionID ? " • active" : "";
562
- const label = `${(s.title || s.id).slice(0, 40)}${active}`;
563
- const selected = s.id === current ? " selected" : "";
564
- return `<option value="${escapeHtml(s.id)}"${selected}>${escapeHtml(label)}</option>`;
565
- }),
634
+
635
+ const rows = [{ id: "", title: "Auto (last active)" }].concat(
636
+ sessions.map((s) => ({ id: s.id, title: `${s.title || s.id}${s.id === autoSessionID ? " • active" : ""}` })),
566
637
  );
567
- // Keep "Auto" selected when no explicit target.
568
- sel.innerHTML = opts.join("");
569
- if (!targetSessionID) sel.value = "";
638
+ menu.innerHTML = rows
639
+ .map((r) => {
640
+ const selCls = (r.id || null) === targetSessionID ? " sel" : "";
641
+ return `<div class="oc-dd-opt${selCls}" data-id="${escapeHtml(r.id)}" title="${escapeHtml(r.title)}">${escapeHtml(r.title)}</div>`;
642
+ })
643
+ .join("");
644
+ menu.querySelectorAll(".oc-dd-opt").forEach((opt) => {
645
+ opt.addEventListener("click", () => {
646
+ targetSessionID = opt.dataset.id || null;
647
+ root.getElementById("oc-dd").classList.remove("open");
648
+ renderSessions();
649
+ });
650
+ });
651
+
652
+ // Button label reflects the effective target.
653
+ const chosen = sessions.find((s) => s.id === current);
654
+ lbl.textContent = targetSessionID && chosen ? chosen.title : "Auto (last active)";
570
655
  }
571
656
 
572
657
  function toast(text, bad) {
@@ -593,7 +678,9 @@
593
678
  if (!annotations.length) return;
594
679
  toast("Submitting…");
595
680
  const sessionID = effectiveTarget() || undefined;
596
- sendMsg({ type: "oc-submit", annotations, sessionID }, (res) => {
681
+ // Strip the local-only thumbnail; the agent receives text + metadata only.
682
+ const clean = annotations.map(({ thumb, ...rest }) => rest);
683
+ sendMsg({ type: "oc-submit", annotations: clean, sessionID }, (res) => {
597
684
  if (!res) {
598
685
  toast("Extension error", true);
599
686
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Select an element in your browser, type an instruction, and send it to your OpenCode agent over a loopback + SSH tunnel. Text and element metadata only (no screenshots).",
5
5
  "keywords": [
6
6
  "opencode",