opencode-browser-annotation-plugin 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -41,7 +41,23 @@ Configure with environment variables if needed:
41
41
  - `OPENCODE_ANNOTATION_PORT` (default `39517`)
42
42
  - `OPENCODE_ANNOTATION_HOST` (default `127.0.0.1`)
43
43
 
44
- ### 2. Extension (in your desktop Chrome)
44
+ ### 2. Skill (on the OpenCode host)
45
+
46
+ The agent learns how to interpret annotations from a bundled skill, so the
47
+ annotation payload stays lean (no repeated instructions). Copy it into your
48
+ OpenCode skills directory:
49
+
50
+ ```bash
51
+ mkdir -p ~/.config/opencode/skills/browser-annotation
52
+ cp node_modules/opencode-browser-annotation-plugin/skills/browser-annotation/SKILL.md \
53
+ ~/.config/opencode/skills/browser-annotation/SKILL.md
54
+ ```
55
+
56
+ Restart OpenCode. The agent auto-loads this skill when an annotation arrives
57
+ (the payload identifies itself and references the skill). Verify with
58
+ `opencode debug skill`.
59
+
60
+ ### 3. Extension (in your desktop Chrome)
45
61
 
46
62
  Until it is on the Chrome Web Store, load it unpacked:
47
63
 
@@ -49,7 +65,7 @@ Until it is on the Chrome Web Store, load it unpacked:
49
65
  2. Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select `extension/`.
50
66
  3. Use a dedicated Chrome profile — the same debug profile you drive with the agent is a good choice.
51
67
 
52
- ### 3. Tunnel (when OpenCode is remote)
68
+ ### 4. Tunnel (when OpenCode is remote)
53
69
 
54
70
  The extension (on your desktop) needs to reach the plugin server (on the host),
55
71
  so use a **local forward**. Run on your desktop, in a separate window:
@@ -72,30 +88,48 @@ extension's **Settings** page.
72
88
 
73
89
  ## Use
74
90
 
75
- 1. Send at least one message in OpenCode so the plugin knows the active session.
91
+ 1. Send at least one message in OpenCode so the plugin knows the active session
92
+ (or pick a target session in the sidebar).
76
93
  2. Press **Alt+A** on any page, then click an element (highlight follows the
77
94
  cursor; works inside shadow DOM). A popup opens next to it.
78
- 3. In the popup, type an instruction and choose **Act now** (agent responds
79
- immediately) or **Queue** (held for context until the next Act). Then either:
95
+ 3. In the popup, type an instruction, then either:
80
96
  - **Send** (or Cmd/Ctrl+Enter) — submit this one right away, no sidebar needed.
81
97
  - **Add to list** — stash it in the sidebar to batch with others.
82
98
  4. Press **Alt+Shift+A** (or click the toolbar icon) to open the **list sidebar**,
83
- review pending annotations, and **Submit** them together. The footer shows the
84
- connection status; a toast confirms how many were sent vs queued.
99
+ review pending annotations (each with a thumbnail), pick the **target session**,
100
+ and **Submit** them together. The footer shows a steady connection status.
85
101
 
86
102
  Shortcuts: `Alt+A` picks an element, `Alt+Shift+A` toggles the list. If they do
87
103
  nothing after loading an unpacked build, set them at `chrome://extensions/shortcuts`.
88
104
 
105
+ ## How the agent handles it
106
+
107
+ When you submit, the plugin injects a short user turn into the chosen session
108
+ that identifies itself as a browser annotation and references the
109
+ `browser-annotation` skill. The agent loads that skill (installed in step 2) to
110
+ interpret the element metadata and locate the code — so the interpretation rules
111
+ live once in the skill, not in every message.
112
+
113
+ If your agents use a project `AGENTS.md`, you can make routing explicit by adding
114
+ a line such as:
115
+
116
+ ```
117
+ When a turn is a browser annotation (it says so and references the
118
+ `browser-annotation` skill), load that skill and use the element metadata
119
+ (component path, data-testid/id/role, ancestors, nearest region, text) to locate
120
+ and change the corresponding code. Confirm the element exists before editing.
121
+ ```
122
+
89
123
  ## Payload
90
124
 
91
125
  The extension POSTs to `/annotations`:
92
126
 
93
127
  ```json
94
128
  {
95
- "extensionVersion": "0.2.0",
129
+ "extensionVersion": "0.6.0",
130
+ "sessionID": "ses_...",
96
131
  "annotations": [
97
132
  {
98
- "mode": "act",
99
133
  "instruction": "Make this button larger and blue",
100
134
  "page": { "url": "https://example.com/app", "title": "My App" },
101
135
  "element": {
@@ -107,22 +141,28 @@ The extension POSTs to `/annotations`:
107
141
  "ariaLabel": "Sign up",
108
142
  "classes": ["cta", "primary"],
109
143
  "text": "Sign up",
144
+ "componentPath": "App > Header > SignupButton",
145
+ "framework": "react",
146
+ "landmark": "header[role=banner]",
147
+ "ancestors": ["div.actions", "header[role=banner]"],
110
148
  "bounds": { "x": 100, "y": 200, "width": 120, "height": 40 },
111
149
  "inShadow": false,
112
- "html": "<button class=\"cta\">Sign up</button>"
150
+ "html": "<button class=\"cta\" id=\"signup\">"
113
151
  }
114
152
  }
115
153
  ]
116
154
  }
117
155
  ```
118
156
 
119
- `mode` is `"act"` (respond now, flushing any queued) or `"queue"` (hold for
120
- context until the next act). The plugin surfaces the most code-locatable
121
- identifiers first (testId, id, name, role) and treats the CSS path and bounds as
122
- weak hints.
157
+ The plugin surfaces the most code-locatable identifiers first (component path,
158
+ testId, id, name, role, ancestors, region) and treats the CSS path and bounds as
159
+ weak hints. Build-generated/hashed class names are filtered out, text is
160
+ whitespace-collapsed, and `html` is the element's opening tag with secret-looking
161
+ attribute values redacted. `sessionID` is optional; without it the plugin targets
162
+ the last-active session.
123
163
 
124
- `GET /status` returns `{ ok, activeSession, sessionID, sessionTitle, queued, host, port }`
125
- for a quick health check and to show the target session.
164
+ `GET /status` returns `{ ok, activeSession, sessionID, sessionTitle, sessions, host, port }`
165
+ for a quick health check and to list/target sessions.
126
166
 
127
167
  ## Develop
128
168
 
package/dist/plugin.js CHANGED
@@ -74,7 +74,7 @@ function formatElement(el) {
74
74
  lines.push(` viewport bounds: ${Math.round(b.width)}×${Math.round(b.height)} at (${Math.round(b.x)}, ${Math.round(b.y)})`);
75
75
  }
76
76
  if (el.html)
77
- lines.push(` outer html: ${truncate(el.html.trim(), 500)}`);
77
+ lines.push(` opening tag: ${truncate(el.html.trim(), 500)}`);
78
78
  return lines.join("\n");
79
79
  }
80
80
  function annotationBlock(a, index, total) {
@@ -92,21 +92,15 @@ function annotationBlock(a, index, total) {
92
92
  .join("\n");
93
93
  }
94
94
  function buildPrompt(annotations) {
95
+ // The static interpretation rules live in the `browser-annotation` skill, not
96
+ // in every payload. The header marks this as a browser annotation and tells
97
+ // the agent to use that skill, so the rules are stated once (in the skill),
98
+ // never repeated per message.
95
99
  const header = annotations.length > 1
96
- ? `The user made ${annotations.length} annotations in the browser. Address each one.`
97
- : "The user made an annotation in the browser.";
100
+ ? `Browser annotations (${annotations.length}) handle using the \`browser-annotation\` skill. Address each one.`
101
+ : "Browser annotation handle using the `browser-annotation` skill.";
98
102
  const blocks = annotations.map((a, i) => annotationBlock(a, i, annotations.length));
99
- return [
100
- header,
101
- "",
102
- ...blocks,
103
- "",
104
- "Guidance:",
105
- "- Locate the code for each element using the most stable identifier available: framework component path first if given, then data-testid, id, name, role, then the ancestor chain and nearest region to disambiguate, then unique class or text. Treat the CSS path and viewport bounds as weak hints only.",
106
- "- Use the ancestors and nearest region to find the right component/file when the element itself is generic (e.g. a bare button that appears many times).",
107
- "- Confirm the element actually exists in this codebase before editing; if you cannot find it, say so instead of guessing.",
108
- "- No screenshot is attached; reason from the metadata and the code.",
109
- ].join("\n");
103
+ return [header, "", ...blocks].join("\n");
110
104
  }
111
105
  async function readJsonBody(req, limitBytes = 2_000_000) {
112
106
  return await new Promise((resolve, reject) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.5.0",
4
+ "version": "0.6.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",
@@ -139,6 +139,7 @@
139
139
  let sessions = []; // [{ id, title, updated }]
140
140
  let targetSessionID = null; // user-chosen target; null = auto (last active)
141
141
  let autoSessionID = null; // the plugin's last-active session
142
+ let lastStatusState = null; // "good" | "bad" | "checking" | null; avoids flicker
142
143
 
143
144
  // A content script keeps running after its extension is reloaded/updated, but
144
145
  // its chrome.* calls then throw "Extension context invalidated". Guard every
@@ -203,6 +204,25 @@
203
204
  return undefined;
204
205
  }
205
206
 
207
+ // Drop build-generated/hashed class names (CSS Modules, styled-components,
208
+ // Emotion, etc.). They change every build and don't map to source, so they are
209
+ // noise for locating code. Keep human-authored classes like "heading-element".
210
+ function isHashedClass(c) {
211
+ if (!c) return true;
212
+ if (/module__/i.test(c)) return true; // CSS Modules: Foo-module__Bar__hHXUL
213
+ if (/__[A-Za-z0-9]{4,}$/.test(c)) return true; // trailing __hash
214
+ if (/^css-[a-z0-9]{5,}$/i.test(c)) return true; // Emotion: css-1ab2c3
215
+ if (/^sc-[A-Za-z0-9]{5,}$/.test(c)) return true; // styled-components: sc-bdVaJa
216
+ if (/^[A-Za-z0-9]{7,}$/.test(c) && /[0-9]/.test(c) && /[A-Z]/.test(c)) return true; // mixed-case+digit opaque hash
217
+ return false;
218
+ }
219
+
220
+ function cleanClasses(list) {
221
+ if (!list || !list.length) return undefined;
222
+ const kept = list.filter((c) => !isHashedClass(c));
223
+ return kept.length ? kept : undefined;
224
+ }
225
+
206
226
  function cssPath(el) {
207
227
  if (!(el instanceof Element)) return "";
208
228
  if (el.id) return `#${CSS.escape(el.id)}`;
@@ -237,7 +257,8 @@
237
257
  else if (el.id) s += `#${el.id}`;
238
258
  const role = el.getAttribute && el.getAttribute("role");
239
259
  if (role) s += `[role=${role}]`;
240
- if (el.classList && el.classList.length) s += "." + Array.from(el.classList).slice(0, 3).join(".");
260
+ const cls = cleanClasses(el.classList ? Array.from(el.classList) : []);
261
+ if (cls) s += "." + cls.slice(0, 3).join(".");
241
262
  return s;
242
263
  }
243
264
 
@@ -317,9 +338,41 @@
317
338
  return undefined;
318
339
  }
319
340
 
341
+ // Collapse runs of whitespace to single spaces and trim, so a container's
342
+ // textContent doesn't arrive as a wall of newlines.
343
+ function cleanText(s) {
344
+ const t = (s || "").replace(/\s+/g, " ").trim();
345
+ return t ? t.slice(0, 300) : undefined;
346
+ }
347
+
348
+ // Names/attributes whose values may be secrets and must never be sent.
349
+ const SECRET_ATTR = /(token|csrf|authenticity|secret|api[-_]?key|password|session|auth|nonce|signature)/i;
350
+
351
+ // For the outer HTML, send only the element's own opening tag (not the whole
352
+ // subtree), with secret-looking attribute values redacted. This avoids leaking
353
+ // CSRF tokens / API keys and keeps the payload small for large containers.
354
+ function safeOpenTag(el) {
355
+ try {
356
+ const tag = el.tagName.toLowerCase();
357
+ const attrs = [];
358
+ for (const a of Array.from(el.attributes || [])) {
359
+ let v = a.value;
360
+ if (SECRET_ATTR.test(a.name) || (a.name === "value" && /password|hidden/i.test(el.getAttribute("type") || ""))) {
361
+ v = "[redacted]";
362
+ } else if (v && v.length > 120) {
363
+ v = v.slice(0, 120) + "…";
364
+ }
365
+ attrs.push(v === "" ? a.name : `${a.name}="${v}"`);
366
+ }
367
+ const open = `<${tag}${attrs.length ? " " + attrs.join(" ") : ""}>`;
368
+ return open.slice(0, 600);
369
+ } catch {
370
+ return undefined;
371
+ }
372
+ }
373
+
320
374
  function elementMeta(el, inShadow) {
321
375
  const r = el.getBoundingClientRect();
322
- const classes = el.classList ? Array.from(el.classList) : [];
323
376
  const fw = frameworkComponents(el);
324
377
  const m = {
325
378
  selector: cssPath(el),
@@ -329,8 +382,8 @@
329
382
  testId: bestTestId(el),
330
383
  role: el.getAttribute("role") || undefined,
331
384
  ariaLabel: el.getAttribute("aria-label") || undefined,
332
- classes: classes.length ? classes : undefined,
333
- text: (el.textContent || "").trim().slice(0, 500) || undefined,
385
+ classes: cleanClasses(el.classList ? Array.from(el.classList) : []),
386
+ text: cleanText(el.textContent),
334
387
  href: el.getAttribute("href") || undefined,
335
388
  src: el.getAttribute("src") || undefined,
336
389
  bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
@@ -340,7 +393,7 @@
340
393
  landmark: nearestLandmark(el),
341
394
  componentPath: fw ? fw.path : undefined,
342
395
  framework: fw ? fw.framework : undefined,
343
- html: (el.outerHTML || "").slice(0, 800),
396
+ html: safeOpenTag(el),
344
397
  };
345
398
  for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
346
399
  if (Array.isArray(m.ancestors) && m.ancestors.length === 0) delete m.ancestors;
@@ -371,18 +424,22 @@
371
424
  const pad = 6 * dpr;
372
425
  let sx = Math.max(0, rect.left * dpr - pad);
373
426
  let sy = Math.max(0, rect.top * dpr - pad);
374
- let sw = Math.min(img.width - sx, rect.width * dpr + pad * 2);
375
- let sh = Math.min(img.height - sy, rect.height * dpr + pad * 2);
427
+ const sw = Math.min(img.width - sx, rect.width * dpr + pad * 2);
428
+ const sh = Math.min(img.height - sy, rect.height * dpr + pad * 2);
376
429
  if (sw <= 0 || sh <= 0) return resolve(null);
377
- // cap output size for a compact list thumbnail
378
- const maxW = 300;
430
+ // Keep the crop at (up to) full captured resolution so it stays sharp
431
+ // on the sidebar's HiDPI display. Only downscale if it's very large,
432
+ // to keep the data URL reasonable.
433
+ const maxW = 900;
379
434
  const scale = Math.min(1, maxW / sw);
380
- const cw = Math.round(sw * scale);
381
- const ch = Math.round(sh * scale);
435
+ const cw = Math.max(1, Math.round(sw * scale));
436
+ const ch = Math.max(1, Math.round(sh * scale));
382
437
  const c = document.createElement("canvas");
383
438
  c.width = cw;
384
439
  c.height = ch;
385
440
  const ctx = c.getContext("2d");
441
+ ctx.imageSmoothingEnabled = true;
442
+ ctx.imageSmoothingQuality = "high";
386
443
  ctx.drawImage(img, sx, sy, sw, sh, 0, 0, cw, ch);
387
444
  resolve(c.toDataURL("image/png"));
388
445
  } catch {
@@ -620,7 +677,7 @@
620
677
  </div>`;
621
678
  root.appendChild(sb);
622
679
  sb.querySelector("#oc-close").addEventListener("click", closeSidebar);
623
- sb.querySelector("#oc-pick").addEventListener("click", () => startPicking());
680
+ sb.querySelector("#oc-pick").addEventListener("click", () => (picking ? stopPicking() : startPicking()));
624
681
  sb.querySelector("#oc-dd-btn").addEventListener("click", (e) => {
625
682
  e.stopPropagation();
626
683
  sb.querySelector("#oc-dd").classList.toggle("open");
@@ -654,6 +711,7 @@
654
711
  clearInterval(statusTimer);
655
712
  statusTimer = null;
656
713
  }
714
+ lastStatusState = null; // re-check cleanly on reopen
657
715
  }
658
716
 
659
717
  function toggleSidebar() {
@@ -693,27 +751,33 @@
693
751
 
694
752
  // ---------- status + submit ----------
695
753
 
754
+ // Apply a status only when it actually changes, so routine polls don't make
755
+ // the text flicker. Only the very first check shows "Checking…".
756
+ function setStatus(state, text) {
757
+ const el = root && root.getElementById("oc-status");
758
+ if (!el) return;
759
+ if (lastStatusState === state) return; // stable during polls
760
+ lastStatusState = state;
761
+ el.className = `oc-status ${state}`;
762
+ el.textContent = text;
763
+ }
764
+
696
765
  function refreshStatus() {
697
766
  if (!root) return;
698
- const el = root.getElementById("oc-status");
699
- if (!el) return;
700
- el.className = "oc-status checking";
701
- el.textContent = "Checking connection…";
767
+ if (lastStatusState === null) setStatus("checking", "Checking connection…");
702
768
  sendMsg({ type: "oc-status" }, (res) => {
703
769
  if (!res) {
704
- el.className = "oc-status bad";
705
- el.textContent = "Extension error";
770
+ setStatus("bad", "Extension error");
706
771
  return;
707
772
  }
708
773
  if (res.ok && res.data?.ok) {
774
+ // Session list updates every poll; the status text stays stable.
709
775
  sessions = Array.isArray(res.data.sessions) ? res.data.sessions : [];
710
776
  autoSessionID = res.data.sessionID || null;
711
777
  renderSessions();
712
- el.className = "oc-status good";
713
- el.textContent = "Connected";
778
+ setStatus("good", "Connected");
714
779
  } else {
715
- el.className = "oc-status bad";
716
- el.textContent = "Not connected — check the SSH tunnel";
780
+ setStatus("bad", "Not connected — check the SSH tunnel");
717
781
  }
718
782
  });
719
783
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "dist",
30
30
  "extension",
31
+ "skills",
31
32
  "README.md"
32
33
  ],
33
34
  "scripts": {
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: browser-annotation
3
+ description: >-
4
+ Interpret and act on browser annotations sent by the
5
+ opencode-browser-annotation extension. Load this whenever a message says it is
6
+ a "Browser annotation" or references the `browser-annotation` skill, or when a
7
+ turn contains a "Selected element:" block with fields like data-testid,
8
+ ancestors, nearest region, or a component path. It explains how to turn that
9
+ element metadata into the correct code location and change.
10
+ ---
11
+
12
+ # Browser annotations
13
+
14
+ Messages from the `opencode-browser-annotation` extension describe a DOM element
15
+ the user selected in their browser plus an instruction. Your job is to map that
16
+ element to the right code and carry out the instruction. The payload is text and
17
+ element metadata only — no screenshot is attached.
18
+
19
+ ## How to locate the code
20
+
21
+ Use the most stable identifier available, in this order:
22
+
23
+ 1. **Framework component path** (e.g. `App > Dashboard > SignupCard`) — the
24
+ strongest hint. Find that component's source file first.
25
+ 2. **`data-testid` / `id` / `name` / `role`** on the element itself — usually
26
+ maps directly to source.
27
+ 3. **Ancestors (nearest first) + nearest region** — use these to disambiguate a
28
+ generic element (e.g. a bare `<button>` that appears many times). They tell
29
+ you which container/feature/file the element belongs to.
30
+ 4. **Unique class or text** — human-authored class names and the element's text
31
+ are good locators. Build-generated/hashed class names are already filtered
32
+ out; treat any remaining opaque tokens as weak.
33
+ 5. **CSS path and viewport bounds** — weak hints only; do not rely on them.
34
+
35
+ ## Rules
36
+
37
+ - Confirm the element actually exists in this codebase before editing. If you
38
+ cannot find it, say so instead of guessing or inventing a file.
39
+ - Reason from the metadata and the actual code; there is no image.
40
+ - The `opening tag` field is the element's own opening tag with secret-looking
41
+ attribute values redacted — do not expect a full HTML subtree, and never try
42
+ to recover redacted values.
43
+ - If several annotations arrive together, address each one.
44
+ - Treat the page URL as context for which route/screen the user was on.
45
+
46
+ ## Field reference
47
+
48
+ - `Element` — tag name.
49
+ - `data-testid`, `id`, `name`, `role`, `aria-label` — element identifiers.
50
+ - `classes` — authored class names (hashed/build-generated ones removed).
51
+ - `text` — the element's visible text (whitespace-collapsed, truncated).
52
+ - `href` / `src` — link or media source when present.
53
+ - `<framework> components` — component hierarchy from React/Preact/Vue when
54
+ detectable.
55
+ - `nearest region` — closest semantic landmark/region ancestor.
56
+ - `ancestors (nearest first)` — parent chain signatures, shadow DOM pierced.
57
+ - `css path` — a positional selector; weak.
58
+ - `context` — notes such as "inside a shadow DOM" or "inside an iframe".
59
+ - `viewport bounds` — on-screen size/position; weak.
60
+ - `opening tag` — redacted opening tag of the element.