opencode-browser-annotation-plugin 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.
- package/dist/plugin.js +64 -12
- package/extension/manifest.json +1 -1
- package/extension/overlay.js +96 -1
- package/package.json +1 -1
package/dist/plugin.js
CHANGED
|
@@ -54,6 +54,12 @@ function formatElement(el) {
|
|
|
54
54
|
lines.push(` src: ${el.src}`);
|
|
55
55
|
if (el.text)
|
|
56
56
|
lines.push(` text: ${JSON.stringify(truncate(el.text.trim(), 200))}`);
|
|
57
|
+
if (el.componentPath)
|
|
58
|
+
lines.push(` ${el.framework ?? "component"} components: ${el.componentPath}`);
|
|
59
|
+
if (el.landmark)
|
|
60
|
+
lines.push(` nearest region: ${el.landmark}`);
|
|
61
|
+
if (el.ancestors && el.ancestors.length)
|
|
62
|
+
lines.push(` ancestors (nearest first): ${el.ancestors.join(" < ")}`);
|
|
57
63
|
if (el.selector)
|
|
58
64
|
lines.push(` css path: ${el.selector}`);
|
|
59
65
|
const context = [];
|
|
@@ -96,7 +102,8 @@ function buildPrompt(annotations) {
|
|
|
96
102
|
...blocks,
|
|
97
103
|
"",
|
|
98
104
|
"Guidance:",
|
|
99
|
-
"- Locate the code for each element using the most stable identifier available
|
|
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).",
|
|
100
107
|
"- Confirm the element actually exists in this codebase before editing; if you cannot find it, say so instead of guessing.",
|
|
101
108
|
"- No screenshot is attached; reason from the metadata and the code.",
|
|
102
109
|
].join("\n");
|
|
@@ -143,30 +150,51 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
143
150
|
const port = envPort();
|
|
144
151
|
let activeSessionID = null;
|
|
145
152
|
let server = null;
|
|
153
|
+
// Sessions that showed activity in THIS OpenCode run (created, messaged, or
|
|
154
|
+
// status/idle events). OpenCode has no open/closed flag, so this approximates
|
|
155
|
+
// "sessions you're currently working in". Deleted sessions are removed.
|
|
156
|
+
const activeIDs = new Set();
|
|
157
|
+
const RECENT_FALLBACK_MS = 2 * 60 * 60 * 1000; // 2h
|
|
158
|
+
const RECENT_FALLBACK_MAX = 8;
|
|
146
159
|
const log = (level, message, extra) => {
|
|
147
160
|
void client.app
|
|
148
161
|
.log({ body: { service: "browser-annotation", level, message, extra } })
|
|
149
162
|
.catch(() => { });
|
|
150
163
|
};
|
|
151
|
-
|
|
152
|
-
* All sessions live in this one OpenCode process, so the plugin can address
|
|
153
|
-
* any of them by id — a single HTTP port/tunnel does not limit targeting.
|
|
154
|
-
* Returns top-level sessions (no parentID), most-recently-updated first.
|
|
155
|
-
*/
|
|
156
|
-
async function listSessions() {
|
|
164
|
+
async function allSessions() {
|
|
157
165
|
try {
|
|
158
166
|
const res = (await client.session.list({ query: { directory } }));
|
|
159
167
|
const rows = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : [];
|
|
160
168
|
return rows
|
|
161
169
|
.filter((s) => s && typeof s.id === "string" && !s.parentID)
|
|
162
|
-
.map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }))
|
|
163
|
-
.sort((a, b) => b.updated - a.updated);
|
|
170
|
+
.map((s) => ({ id: s.id, title: typeof s.title === "string" ? s.title : s.id, updated: s.time?.updated ?? 0 }));
|
|
164
171
|
}
|
|
165
172
|
catch (error) {
|
|
166
173
|
log("warn", `session.list failed: ${error instanceof Error ? error.message : "unknown"}`);
|
|
167
174
|
return [];
|
|
168
175
|
}
|
|
169
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The picker list: sessions active in this run (newest first). If none have
|
|
179
|
+
* been observed yet (e.g. right after a restart), fall back to the most
|
|
180
|
+
* recently updated few so the picker is not empty. All live in this one
|
|
181
|
+
* process, so any can be targeted over the single port.
|
|
182
|
+
*/
|
|
183
|
+
async function listSessions() {
|
|
184
|
+
const all = await allSessions();
|
|
185
|
+
const byRecent = [...all].sort((a, b) => b.updated - a.updated);
|
|
186
|
+
// Prune ids that no longer exist.
|
|
187
|
+
const existing = new Set(all.map((s) => s.id));
|
|
188
|
+
for (const id of activeIDs)
|
|
189
|
+
if (!existing.has(id))
|
|
190
|
+
activeIDs.delete(id);
|
|
191
|
+
if (activeIDs.size > 0) {
|
|
192
|
+
return byRecent.filter((s) => activeIDs.has(s.id));
|
|
193
|
+
}
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const recent = byRecent.filter((s) => now - s.updated < RECENT_FALLBACK_MS).slice(0, RECENT_FALLBACK_MAX);
|
|
196
|
+
return recent.length ? recent : byRecent.slice(0, RECENT_FALLBACK_MAX);
|
|
197
|
+
}
|
|
170
198
|
async function injectPrompt(sessionID, annotations) {
|
|
171
199
|
try {
|
|
172
200
|
await client.session.promptAsync({
|
|
@@ -269,12 +297,36 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
|
|
|
269
297
|
start();
|
|
270
298
|
return {
|
|
271
299
|
"chat.message": async (input) => {
|
|
272
|
-
if (input?.sessionID)
|
|
300
|
+
if (input?.sessionID) {
|
|
273
301
|
activeSessionID = input.sessionID;
|
|
302
|
+
activeIDs.add(input.sessionID);
|
|
303
|
+
}
|
|
274
304
|
},
|
|
275
305
|
event: async ({ event }) => {
|
|
276
|
-
if (event.type === "session.
|
|
277
|
-
|
|
306
|
+
if (event.type === "session.created") {
|
|
307
|
+
const id = event.properties.info.id;
|
|
308
|
+
if (id)
|
|
309
|
+
activeIDs.add(id);
|
|
310
|
+
}
|
|
311
|
+
else if (event.type === "session.updated") {
|
|
312
|
+
const id = event.properties.info.id;
|
|
313
|
+
if (id)
|
|
314
|
+
activeIDs.add(id);
|
|
315
|
+
}
|
|
316
|
+
else if (event.type === "session.status") {
|
|
317
|
+
const id = event.properties.sessionID;
|
|
318
|
+
if (id)
|
|
319
|
+
activeIDs.add(id);
|
|
320
|
+
}
|
|
321
|
+
else if (event.type === "session.idle") {
|
|
322
|
+
const id = event.properties.sessionID;
|
|
323
|
+
if (id)
|
|
324
|
+
activeIDs.add(id);
|
|
325
|
+
}
|
|
326
|
+
else if (event.type === "session.deleted") {
|
|
327
|
+
const id = event.properties.info.id;
|
|
328
|
+
activeIDs.delete(id);
|
|
329
|
+
if (id === activeSessionID)
|
|
278
330
|
activeSessionID = null;
|
|
279
331
|
}
|
|
280
332
|
},
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCode Browser Annotation",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.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",
|
package/extension/overlay.js
CHANGED
|
@@ -228,9 +228,99 @@
|
|
|
228
228
|
return parts.join(" > ");
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// A compact one-line signature of an element: tag + the most locating bits.
|
|
232
|
+
function elemSignature(el) {
|
|
233
|
+
if (!(el instanceof Element)) return "";
|
|
234
|
+
let s = el.tagName.toLowerCase();
|
|
235
|
+
const tid = bestTestId(el);
|
|
236
|
+
if (tid) s += `[data-testid=${tid}]`;
|
|
237
|
+
else if (el.id) s += `#${el.id}`;
|
|
238
|
+
const role = el.getAttribute && el.getAttribute("role");
|
|
239
|
+
if (role) s += `[role=${role}]`;
|
|
240
|
+
if (el.classList && el.classList.length) s += "." + Array.from(el.classList).slice(0, 3).join(".");
|
|
241
|
+
return s;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Ancestor chain from the parent up to (but excluding) <body>, piercing shadow
|
|
245
|
+
// roots. Nearest first. Gives the agent "where in the tree" this element is.
|
|
246
|
+
function ancestorChain(el, max = 6) {
|
|
247
|
+
const chain = [];
|
|
248
|
+
let node = el.parentElement || (el.getRootNode && el.getRootNode().host) || null;
|
|
249
|
+
while (node && node.nodeType === 1 && chain.length < max) {
|
|
250
|
+
const tag = node.tagName ? node.tagName.toLowerCase() : "";
|
|
251
|
+
if (tag === "body" || tag === "html") break;
|
|
252
|
+
chain.push(elemSignature(node));
|
|
253
|
+
node = node.parentElement || (node.getRootNode && node.getRootNode() instanceof ShadowRoot ? node.getRootNode().host : null);
|
|
254
|
+
}
|
|
255
|
+
return chain;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Nearest semantic landmark/region ancestor (main, nav, header, dialog, form,
|
|
259
|
+
// or any element with a data-component / data-testid). Tells the agent which
|
|
260
|
+
// feature/region of the page the element belongs to.
|
|
261
|
+
function nearestLandmark(el) {
|
|
262
|
+
const landmarkTags = new Set(["main", "nav", "header", "footer", "aside", "section", "article", "form", "dialog"]);
|
|
263
|
+
const landmarkRoles = new Set(["main", "navigation", "banner", "contentinfo", "dialog", "form", "search", "region"]);
|
|
264
|
+
let node = el.parentElement;
|
|
265
|
+
while (node && node.nodeType === 1) {
|
|
266
|
+
const tag = node.tagName.toLowerCase();
|
|
267
|
+
const role = node.getAttribute("role");
|
|
268
|
+
const comp = node.getAttribute("data-component") || node.getAttribute("data-testid");
|
|
269
|
+
if (landmarkTags.has(tag) || (role && landmarkRoles.has(role)) || comp) {
|
|
270
|
+
return elemSignature(node);
|
|
271
|
+
}
|
|
272
|
+
if (tag === "body") break;
|
|
273
|
+
node = node.parentElement;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Best-effort framework component path (React/Preact fiber, or Vue). Returns
|
|
279
|
+
// e.g. "App > Dashboard > SignupCard". Absent on non-framework pages.
|
|
280
|
+
function frameworkComponents(el) {
|
|
281
|
+
try {
|
|
282
|
+
const keys = Object.keys(el);
|
|
283
|
+
const fiberKey = keys.find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"));
|
|
284
|
+
if (fiberKey) {
|
|
285
|
+
const names = [];
|
|
286
|
+
let cur = el[fiberKey];
|
|
287
|
+
let depth = 0;
|
|
288
|
+
while (cur && depth < 12) {
|
|
289
|
+
const t = cur.type;
|
|
290
|
+
let name = null;
|
|
291
|
+
if (typeof t === "function") name = t.displayName || t.name || null;
|
|
292
|
+
else if (t && typeof t === "object") name = (t.render && (t.render.displayName || t.render.name)) || (t.type && (t.type.displayName || t.type.name)) || null;
|
|
293
|
+
if (name && !name.startsWith("_") && name !== "Fragment") names.unshift(name);
|
|
294
|
+
cur = cur.return;
|
|
295
|
+
depth++;
|
|
296
|
+
}
|
|
297
|
+
if (names.length) return { framework: "react", path: names.slice(-6).join(" > ") };
|
|
298
|
+
}
|
|
299
|
+
// Vue 3 exposes __vueParentComponent; Vue 2 exposes __vue__.
|
|
300
|
+
let vnode = el.__vueParentComponent || (el.__vue__ && el.__vue__.$);
|
|
301
|
+
if (vnode) {
|
|
302
|
+
const names = [];
|
|
303
|
+
let cur = vnode;
|
|
304
|
+
let depth = 0;
|
|
305
|
+
while (cur && depth < 12) {
|
|
306
|
+
const type = cur.type || (cur.$options && cur.$options);
|
|
307
|
+
const name = (type && (type.name || type.__name)) || (cur.$options && cur.$options.name) || null;
|
|
308
|
+
if (name) names.unshift(name);
|
|
309
|
+
cur = cur.parent || (cur.$parent && cur.$parent.$);
|
|
310
|
+
depth++;
|
|
311
|
+
}
|
|
312
|
+
if (names.length) return { framework: "vue", path: names.slice(-6).join(" > ") };
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
/* ignore */
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
231
320
|
function elementMeta(el, inShadow) {
|
|
232
321
|
const r = el.getBoundingClientRect();
|
|
233
322
|
const classes = el.classList ? Array.from(el.classList) : [];
|
|
323
|
+
const fw = frameworkComponents(el);
|
|
234
324
|
const m = {
|
|
235
325
|
selector: cssPath(el),
|
|
236
326
|
tag: el.tagName,
|
|
@@ -246,9 +336,14 @@
|
|
|
246
336
|
bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
|
|
247
337
|
inShadow: Boolean(inShadow),
|
|
248
338
|
inIframe: window.top !== window.self,
|
|
339
|
+
ancestors: ancestorChain(el),
|
|
340
|
+
landmark: nearestLandmark(el),
|
|
341
|
+
componentPath: fw ? fw.path : undefined,
|
|
342
|
+
framework: fw ? fw.framework : undefined,
|
|
249
343
|
html: (el.outerHTML || "").slice(0, 800),
|
|
250
344
|
};
|
|
251
345
|
for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
|
|
346
|
+
if (Array.isArray(m.ancestors) && m.ancestors.length === 0) delete m.ancestors;
|
|
252
347
|
return m;
|
|
253
348
|
}
|
|
254
349
|
|
|
@@ -548,7 +643,7 @@
|
|
|
548
643
|
sb.style.display = "flex";
|
|
549
644
|
renderCards();
|
|
550
645
|
refreshStatus();
|
|
551
|
-
if (!statusTimer) statusTimer = setInterval(refreshStatus,
|
|
646
|
+
if (!statusTimer) statusTimer = setInterval(refreshStatus, 3500);
|
|
552
647
|
}
|
|
553
648
|
|
|
554
649
|
function closeSidebar() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-browser-annotation-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|