opencode-browser-annotation-plugin 0.1.3 → 0.2.1

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
@@ -12,16 +12,16 @@ the code from the selector/DOM context you send.
12
12
 
13
13
  ```
14
14
  Desktop Chrome (extension)
15
- └─ pick element + type instruction list → [Submit]
16
- └─ POST http://127.0.0.1:39517/annotations (via ssh -R when remote)
15
+ └─ Alt+A → overlay + sidebar → pick element, type instruction, Act/Queue → [Submit]
16
+ └─ POST http://127.0.0.1:39517/annotations (via ssh -L when remote)
17
17
  OpenCode host (plugin)
18
18
  └─ HTTP server on 127.0.0.1 → injects a turn into the active session
19
19
  └─ agent receives the instruction + element metadata and acts
20
20
  ```
21
21
 
22
22
  The plugin binds to `127.0.0.1` only. When OpenCode is on a remote host, an
23
- `ssh -R` reverse tunnel carries the extension's POST to it — no public port, and
24
- SSH provides the auth and encryption.
23
+ `ssh -L` local forward carries the extension's POST from your desktop to it — no
24
+ public port, and SSH provides the auth and encryption.
25
25
 
26
26
  ## Install
27
27
 
@@ -51,16 +51,19 @@ Until it is on the Chrome Web Store, load it unpacked:
51
51
 
52
52
  ### 3. Tunnel (when OpenCode is remote)
53
53
 
54
- On your desktop, forward the plugin port to the server (separate window):
54
+ The extension (on your desktop) needs to reach the plugin server (on the host),
55
+ so use a **local forward**. Run on your desktop, in a separate window:
55
56
 
56
57
  ```bash
57
- ssh -N -R 39517:127.0.0.1:39517 you@server
58
+ ssh -N -L 39517:127.0.0.1:39517 you@host
58
59
  ```
59
60
 
60
- If you also drive the browser from the agent (desktop-drive), forward both:
61
+ If you also drive the browser from the agent (desktop-drive on 9333), that is the
62
+ opposite direction (host → desktop), so it uses a reverse forward. You can run
63
+ both at once:
61
64
 
62
65
  ```bash
63
- ssh -N -R 9333:127.0.0.1:9333 -R 39517:127.0.0.1:39517 you@server
66
+ ssh -N -L 39517:127.0.0.1:39517 -R 9333:127.0.0.1:9333 you@host
64
67
  ```
65
68
 
66
69
  If OpenCode runs on the same machine as your browser, no tunnel is needed — the
@@ -70,10 +73,15 @@ extension's **Settings** page.
70
73
  ## Use
71
74
 
72
75
  1. Send at least one message in OpenCode so the plugin knows the active session.
73
- 2. Click the extension icon **Select element**.
74
- 3. Hover to highlight, click the element, type your instruction (Esc cancels).
75
- 4. Repeat to queue more, then **Submit to agent**.
76
- 5. The agent receives the annotations as a new turn and responds.
76
+ 2. Press **Alt+A** on any page to open the annotation overlay (or click the
77
+ toolbar icon). Press Alt+A again or Esc to close it.
78
+ 3. In the sidebar, click **Select element**, hover to highlight, and click the
79
+ element you want (works inside shadow DOM).
80
+ 4. Type an instruction in the card. Choose **Act now** (agent responds
81
+ immediately) or **Queue** (held for context until the next Act). Repeat for
82
+ more elements.
83
+ 5. Click **Submit to agent**. The sidebar footer shows the connection and active
84
+ session; a toast confirms how many were sent vs queued.
77
85
 
78
86
  ## Payload
79
87
 
@@ -81,18 +89,23 @@ The extension POSTs to `/annotations`:
81
89
 
82
90
  ```json
83
91
  {
84
- "extensionVersion": "0.1.0",
92
+ "extensionVersion": "0.2.0",
85
93
  "annotations": [
86
94
  {
95
+ "mode": "act",
87
96
  "instruction": "Make this button larger and blue",
88
97
  "page": { "url": "https://example.com/app", "title": "My App" },
89
98
  "element": {
90
99
  "selector": "button.cta",
91
100
  "tag": "BUTTON",
92
- "text": "Sign up",
101
+ "id": "signup",
102
+ "testId": "signup-cta",
93
103
  "role": "button",
94
104
  "ariaLabel": "Sign up",
105
+ "classes": ["cta", "primary"],
106
+ "text": "Sign up",
95
107
  "bounds": { "x": 100, "y": 200, "width": 120, "height": 40 },
108
+ "inShadow": false,
96
109
  "html": "<button class=\"cta\">Sign up</button>"
97
110
  }
98
111
  }
@@ -100,7 +113,13 @@ The extension POSTs to `/annotations`:
100
113
  }
101
114
  ```
102
115
 
103
- `GET /status` returns `{ ok, activeSession, host, port }` for a quick health check.
116
+ `mode` is `"act"` (respond now, flushing any queued) or `"queue"` (hold for
117
+ context until the next act). The plugin surfaces the most code-locatable
118
+ identifiers first (testId, id, name, role) and treats the CSS path and bounds as
119
+ weak hints.
120
+
121
+ `GET /status` returns `{ ok, activeSession, sessionID, sessionTitle, queued, host, port }`
122
+ for a quick health check and to show the target session.
104
123
 
105
124
  ## Develop
106
125
 
package/dist/plugin.js CHANGED
@@ -3,10 +3,13 @@ import { createServer } from "node:http";
3
3
  * opencode-browser-annotation-plugin
4
4
  *
5
5
  * Runs a loopback HTTP server on the OpenCode host. A browser extension POSTs
6
- * annotations (a typed instruction plus selected-element metadata) to it, over
7
- * an `ssh -R` reverse tunnel when the browser is on a separate desktop. On
8
- * receipt, the plugin injects a new user turn into the most recently active
9
- * OpenCode session so the agent responds.
6
+ * annotations (a typed instruction plus selected-element metadata) to it. When
7
+ * the browser runs on a separate desktop, the extension reaches this server over
8
+ * an `ssh -L` local forward (desktop -> host).
9
+ *
10
+ * On receipt the plugin injects a new user turn into the most recently active
11
+ * OpenCode session so the agent responds. Annotations may be acted on
12
+ * immediately ("act") or queued and flushed with a later act ("queue").
10
13
  *
11
14
  * Scope: text + element metadata only. No screenshots, no image/vision.
12
15
  */
@@ -23,52 +26,79 @@ function envPort() {
23
26
  function truncate(value, max) {
24
27
  return value.length > max ? `${value.slice(0, max)}…` : value;
25
28
  }
29
+ /**
30
+ * Surface the most code-locatable identifiers first: test ids and framework
31
+ * hooks are what map to source, not pixel bounds.
32
+ */
26
33
  function formatElement(el) {
27
34
  if (!el)
28
- return "- (no element captured)";
35
+ return " (no element captured)";
29
36
  const lines = [];
30
- if (el.tag)
31
- lines.push(`- Tag: ${el.tag}`);
32
- if (el.selector)
33
- lines.push(`- Selector: ${el.selector}`);
37
+ const tag = el.tag ? el.tag.toLowerCase() : "element";
38
+ lines.push(` Element: <${tag}>`);
39
+ if (el.testId)
40
+ lines.push(` data-testid: ${el.testId}`);
41
+ if (el.id)
42
+ lines.push(` id: ${el.id}`);
43
+ if (el.name)
44
+ lines.push(` name: ${el.name}`);
34
45
  if (el.role)
35
- lines.push(`- Role: ${el.role}`);
46
+ lines.push(` role: ${el.role}`);
36
47
  if (el.ariaLabel)
37
- lines.push(`- ARIA label: ${el.ariaLabel}`);
48
+ lines.push(` aria-label: ${el.ariaLabel}`);
49
+ if (el.classes && el.classes.length)
50
+ lines.push(` classes: ${el.classes.slice(0, 8).join(" ")}`);
51
+ if (el.href)
52
+ lines.push(` href: ${el.href}`);
53
+ if (el.src)
54
+ lines.push(` src: ${el.src}`);
38
55
  if (el.text)
39
- lines.push(`- Text: ${truncate(el.text.trim(), 300)}`);
56
+ lines.push(` text: ${JSON.stringify(truncate(el.text.trim(), 200))}`);
57
+ if (el.selector)
58
+ lines.push(` css path: ${el.selector}`);
59
+ const context = [];
60
+ if (el.inShadow)
61
+ context.push("inside a shadow DOM");
62
+ if (el.inIframe)
63
+ context.push(`inside an iframe${el.framePath ? ` (${el.framePath})` : ""}`);
64
+ if (context.length)
65
+ lines.push(` context: ${context.join(", ")}`);
40
66
  if (el.bounds) {
41
67
  const b = el.bounds;
42
- lines.push(`- Bounds: x=${Math.round(b.x)} y=${Math.round(b.y)} w=${Math.round(b.width)} h=${Math.round(b.height)}`);
68
+ lines.push(` viewport bounds: ${Math.round(b.width)}×${Math.round(b.height)} at (${Math.round(b.x)}, ${Math.round(b.y)})`);
43
69
  }
44
70
  if (el.html)
45
- lines.push(`- Outer HTML: ${truncate(el.html.trim(), 600)}`);
46
- return lines.length ? lines.join("\n") : "- (no element details)";
71
+ lines.push(` outer html: ${truncate(el.html.trim(), 500)}`);
72
+ return lines.join("\n");
73
+ }
74
+ function annotationBlock(a, index, total) {
75
+ const label = total > 1 ? ` ${index + 1}` : "";
76
+ const page = a.page ?? {};
77
+ const instruction = (a.instruction ?? "").trim() || "(no instruction text)";
78
+ return [
79
+ `### Annotation${label}`,
80
+ `Instruction: ${instruction}`,
81
+ page.url ? `Page: ${page.title ? `${page.title} — ` : ""}${page.url}` : "",
82
+ "Selected element:",
83
+ formatElement(a.element),
84
+ ]
85
+ .filter(Boolean)
86
+ .join("\n");
47
87
  }
48
88
  function buildPrompt(annotations) {
49
89
  const header = annotations.length > 1
50
- ? `The user submitted ${annotations.length} browser annotations. Address each one.`
51
- : "The user submitted a browser annotation.";
52
- const blocks = annotations.map((a, i) => {
53
- const n = annotations.length > 1 ? ` ${i + 1}` : "";
54
- const page = a.page ?? {};
55
- const instruction = (a.instruction ?? "").trim() || "(no instruction text)";
56
- return [
57
- `## Annotation${n}`,
58
- `Instruction: ${instruction}`,
59
- page.url ? `Page: ${page.title ? `${page.title} — ` : ""}${page.url}` : "",
60
- "Selected element:",
61
- formatElement(a.element),
62
- ]
63
- .filter(Boolean)
64
- .join("\n");
65
- });
90
+ ? `The user made ${annotations.length} annotations in the browser. Address each one.`
91
+ : "The user made an annotation in the browser.";
92
+ const blocks = annotations.map((a, i) => annotationBlock(a, i, annotations.length));
66
93
  return [
67
94
  header,
68
95
  "",
69
96
  ...blocks,
70
97
  "",
71
- "Use the element metadata to locate the relevant code and make the requested change. No screenshot is attached.",
98
+ "Guidance:",
99
+ "- Locate the code for each element using the most stable identifier available (data-testid, id, name, role, then unique class or text). Treat the CSS path and viewport bounds as weak hints only.",
100
+ "- Confirm the element actually exists in this codebase before editing; if you cannot find it, say so instead of guessing.",
101
+ "- No screenshot is attached; reason from the metadata and the code.",
72
102
  ].join("\n");
73
103
  }
74
104
  async function readJsonBody(req, limitBytes = 2_000_000) {
@@ -112,38 +142,71 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
112
142
  const host = envHost();
113
143
  const port = envPort();
114
144
  let activeSessionID = null;
145
+ let activeSessionTitle = null;
115
146
  let server = null;
147
+ const queued = [];
116
148
  const log = (level, message, extra) => {
117
149
  void client.app
118
150
  .log({ body: { service: "browser-annotation", level, message, extra } })
119
151
  .catch(() => { });
120
152
  };
121
- async function inject(annotations) {
122
- if (!activeSessionID) {
123
- return { ok: false, error: "No active OpenCode session yet. Send a message in OpenCode first." };
124
- }
153
+ async function injectPrompt(annotations) {
125
154
  try {
126
- // promptAsync injects the turn without blocking on the agent's full
127
- // response, so the extension gets a prompt acknowledgement.
128
155
  await client.session.promptAsync({
129
156
  path: { id: activeSessionID },
130
157
  query: { directory },
131
158
  body: { parts: [{ type: "text", text: buildPrompt(annotations) }] },
132
159
  });
133
- return { ok: true, sessionID: activeSessionID };
160
+ return { ok: true };
134
161
  }
135
162
  catch (error) {
136
- const message = error instanceof Error ? error.message : "session.prompt failed";
137
- return { ok: false, error: message, sessionID: activeSessionID };
163
+ return { ok: false, error: error instanceof Error ? error.message : "session.prompt failed" };
138
164
  }
139
165
  }
166
+ /**
167
+ * Queue annotations are held until an "act" annotation arrives (or all-queue
168
+ * submits nothing yet). An act annotation flushes the queue plus itself.
169
+ */
170
+ async function handleSubmit(annotations) {
171
+ if (!activeSessionID) {
172
+ return {
173
+ ok: false,
174
+ error: "No active OpenCode session yet. Send a message in OpenCode first.",
175
+ injected: 0,
176
+ queued: queued.length,
177
+ };
178
+ }
179
+ const toQueue = annotations.filter((a) => a.mode === "queue");
180
+ const toAct = annotations.filter((a) => a.mode !== "queue");
181
+ queued.push(...toQueue);
182
+ if (toAct.length === 0) {
183
+ return { ok: true, injected: 0, queued: queued.length, sessionID: activeSessionID };
184
+ }
185
+ const batch = [...queued, ...toAct];
186
+ queued.length = 0;
187
+ const result = await injectPrompt(batch);
188
+ if (!result.ok) {
189
+ // Re-queue so nothing is lost.
190
+ queued.unshift(...batch.filter((a) => a.mode === "queue"));
191
+ return { ok: false, error: result.error, injected: 0, queued: queued.length, sessionID: activeSessionID };
192
+ }
193
+ return { ok: true, injected: batch.length, queued: queued.length, sessionID: activeSessionID };
194
+ }
140
195
  function handle(req, res) {
141
196
  if (req.method === "OPTIONS") {
142
197
  sendJson(res, 204, {});
143
198
  return;
144
199
  }
145
200
  if (req.method === "GET" && req.url === "/status") {
146
- sendJson(res, 200, { ok: true, activeSession: Boolean(activeSessionID), host, port });
201
+ sendJson(res, 200, {
202
+ ok: true,
203
+ activeSession: Boolean(activeSessionID),
204
+ sessionID: activeSessionID,
205
+ sessionTitle: activeSessionTitle,
206
+ queued: queued.length,
207
+ host,
208
+ port,
209
+ });
147
210
  return;
148
211
  }
149
212
  if (req.method === "POST" && req.url === "/annotations") {
@@ -155,13 +218,15 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
155
218
  sendJson(res, 400, { ok: false, error: "No annotations in payload." });
156
219
  return;
157
220
  }
158
- const result = await inject(annotations);
221
+ const result = await handleSubmit(annotations);
159
222
  if (result.ok) {
160
- log("info", `Injected ${annotations.length} annotation(s)`, { sessionID: result.sessionID });
161
- sendJson(res, 200, { ok: true, count: annotations.length, sessionID: result.sessionID });
223
+ log("info", `Annotations: injected ${result.injected}, queued ${result.queued}`, {
224
+ sessionID: result.sessionID,
225
+ });
226
+ sendJson(res, 200, result);
162
227
  }
163
228
  else {
164
- log("warn", `Annotation injection failed: ${result.error}`);
229
+ log("warn", `Annotation submit failed: ${result.error}`);
165
230
  sendJson(res, 409, result);
166
231
  }
167
232
  })
@@ -198,9 +263,17 @@ export const BrowserAnnotationPlugin = async ({ client, directory }) => {
198
263
  activeSessionID = input.sessionID;
199
264
  },
200
265
  event: async ({ event }) => {
201
- if (event.type === "session.deleted") {
202
- if (event.properties.info.id === activeSessionID)
266
+ if (event.type === "session.updated") {
267
+ const info = event.properties.info;
268
+ if (info.id === activeSessionID && typeof info.title === "string") {
269
+ activeSessionTitle = info.title;
270
+ }
271
+ }
272
+ else if (event.type === "session.deleted") {
273
+ if (event.properties.info.id === activeSessionID) {
203
274
  activeSessionID = null;
275
+ activeSessionTitle = null;
276
+ }
204
277
  }
205
278
  },
206
279
  };
@@ -1,6 +1,8 @@
1
- // Background service worker: holds the annotation list and submits the batch to
2
- // the plugin's loopback HTTP server (reached over an ssh -R tunnel when the
3
- // OpenCode host is remote).
1
+ // Background service worker.
2
+ // - Toggles the annotation overlay on Alt+A (command) or toolbar click.
3
+ // - Performs all network I/O to the plugin endpoint (status + submit), because
4
+ // page CSP can block a content script from fetching localhost. Results are
5
+ // returned to the overlay via message responses.
4
6
 
5
7
  const DEFAULT_ENDPOINT = "http://127.0.0.1:39517";
6
8
  const EXTENSION_VERSION = chrome.runtime.getManifest().version;
@@ -10,47 +12,52 @@ async function getEndpoint() {
10
12
  return (endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
11
13
  }
12
14
 
13
- async function getAnnotations() {
14
- const { annotations } = await chrome.storage.local.get("annotations");
15
- return Array.isArray(annotations) ? annotations : [];
15
+ async function toggleOverlay(tab) {
16
+ if (!tab?.id) return;
17
+ try {
18
+ // The overlay content script listens for this and toggles itself. If it is
19
+ // not injected yet, inject it first, then it opens on load.
20
+ const [{ result } = {}] = await chrome.scripting.executeScript({
21
+ target: { tabId: tab.id },
22
+ func: () => Boolean(window.__ocAnnotationInjected),
23
+ });
24
+ if (!result) {
25
+ await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["overlay.js"] });
26
+ } else {
27
+ await chrome.tabs.sendMessage(tab.id, { type: "oc-toggle" });
28
+ }
29
+ } catch {
30
+ // e.g. chrome:// pages or the web store cannot be injected.
31
+ }
16
32
  }
17
33
 
18
- async function setAnnotations(list) {
19
- await chrome.storage.local.set({ annotations: list });
20
- }
34
+ chrome.commands.onCommand.addListener(async (command) => {
35
+ if (command !== "toggle-overlay") return;
36
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
37
+ await toggleOverlay(tab);
38
+ });
39
+
40
+ chrome.action.onClicked.addListener((tab) => {
41
+ void toggleOverlay(tab);
42
+ });
21
43
 
22
44
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
23
45
  (async () => {
24
- if (msg?.type === "oc-add-annotation" && msg.annotation) {
25
- const list = await getAnnotations();
26
- list.push({ ...msg.annotation, ts: Date.now() });
27
- await setAnnotations(list);
28
- sendResponse({ ok: true, count: list.length });
29
- return;
30
- }
31
-
32
- if (msg?.type === "oc-list") {
33
- sendResponse({ ok: true, annotations: await getAnnotations() });
34
- return;
35
- }
36
-
37
- if (msg?.type === "oc-clear") {
38
- await setAnnotations([]);
39
- sendResponse({ ok: true });
40
- return;
41
- }
42
-
43
- if (msg?.type === "oc-remove" && typeof msg.index === "number") {
44
- const list = await getAnnotations();
45
- list.splice(msg.index, 1);
46
- await setAnnotations(list);
47
- sendResponse({ ok: true, annotations: list });
46
+ if (msg?.type === "oc-status") {
47
+ try {
48
+ const endpoint = await getEndpoint();
49
+ const res = await fetch(`${endpoint}/status`, { method: "GET" });
50
+ const data = await res.json().catch(() => ({}));
51
+ sendResponse({ ok: res.ok, endpoint, data });
52
+ } catch (error) {
53
+ sendResponse({ ok: false, error: error?.message || "unreachable" });
54
+ }
48
55
  return;
49
56
  }
50
57
 
51
58
  if (msg?.type === "oc-submit") {
52
- const list = await getAnnotations();
53
- if (list.length === 0) {
59
+ const annotations = Array.isArray(msg.annotations) ? msg.annotations : [];
60
+ if (annotations.length === 0) {
54
61
  sendResponse({ ok: false, error: "No annotations to submit." });
55
62
  return;
56
63
  }
@@ -59,12 +66,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
59
66
  const res = await fetch(`${endpoint}/annotations`, {
60
67
  method: "POST",
61
68
  headers: { "content-type": "application/json" },
62
- body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations: list }),
69
+ body: JSON.stringify({ extensionVersion: EXTENSION_VERSION, annotations }),
63
70
  });
64
71
  const data = await res.json().catch(() => ({}));
65
72
  if (res.ok && data.ok) {
66
- await setAnnotations([]);
67
- sendResponse({ ok: true, count: data.count });
73
+ sendResponse({ ok: true, injected: data.injected ?? 0, queued: data.queued ?? 0, sessionID: data.sessionID });
68
74
  } else {
69
75
  sendResponse({ ok: false, error: data.error || `HTTP ${res.status}` });
70
76
  }
@@ -1,17 +1,32 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.1.0",
5
- "description": "Select an element, type an instruction, and send it to your OpenCode agent. Text and element metadata only.",
6
- "permissions": ["activeTab", "scripting", "storage"],
7
- "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
8
- "action": {
9
- "default_popup": "popup.html",
10
- "default_title": "OpenCode Annotation"
11
- },
4
+ "version": "0.2.1",
5
+ "description": "Alt+A to annotate elements and send them to your OpenCode agent. Text and element metadata only.",
6
+ "permissions": [
7
+ "activeTab",
8
+ "scripting",
9
+ "storage"
10
+ ],
11
+ "host_permissions": [
12
+ "http://127.0.0.1/*",
13
+ "http://localhost/*"
14
+ ],
12
15
  "background": {
13
16
  "service_worker": "background.js",
14
17
  "type": "module"
15
18
  },
16
- "options_page": "options.html"
19
+ "action": {
20
+ "default_title": "Toggle OpenCode Annotation (Alt+A)"
21
+ },
22
+ "options_page": "options.html",
23
+ "commands": {
24
+ "toggle-overlay": {
25
+ "suggested_key": {
26
+ "default": "Alt+A",
27
+ "mac": "Alt+A"
28
+ },
29
+ "description": "Toggle the annotation overlay"
30
+ }
31
+ }
17
32
  }
@@ -3,65 +3,44 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <style>
6
+ :root { color-scheme: light; }
6
7
  body {
7
- font-family: system-ui, sans-serif;
8
- max-width: 520px;
9
- margin: 24px auto;
10
- padding: 0 16px;
11
- color: #1a1a1a;
12
- }
13
- label {
14
- display: block;
15
- font-weight: 600;
16
- margin-bottom: 6px;
8
+ font: 14px/1.5 system-ui, -apple-system, sans-serif;
9
+ max-width: 540px; margin: 40px auto; padding: 0 20px; color: #1c1d21;
17
10
  }
11
+ h1 { font-size: 18px; margin: 0 0 4px; }
12
+ p.sub { color: #6a6d75; margin: 0 0 24px; }
13
+ label { display: block; font-weight: 650; margin-bottom: 6px; }
18
14
  input {
19
- width: 100%;
20
- font: inherit;
21
- padding: 8px;
22
- border: 1px solid #ccc;
23
- border-radius: 6px;
24
- box-sizing: border-box;
15
+ width: 100%; box-sizing: border-box; font: inherit;
16
+ padding: 10px 12px; border: 1px solid rgba(0,0,0,.15); border-radius: 10px;
25
17
  }
18
+ input:focus { outline: none; border-color: #2b7cff; box-shadow: 0 0 0 3px rgba(43,124,255,.15); }
26
19
  button {
27
- font: inherit;
28
- margin-top: 12px;
29
- padding: 8px 14px;
30
- border: 1px solid #2b7cff;
31
- border-radius: 6px;
32
- background: #2b7cff;
33
- color: #fff;
34
- cursor: pointer;
35
- }
36
- p {
37
- color: #555;
38
- font-size: 13px;
39
- line-height: 1.5;
40
- }
41
- code {
42
- background: #f2f2f2;
43
- padding: 1px 4px;
44
- border-radius: 4px;
45
- }
46
- #saved {
47
- color: #0a0;
48
- font-size: 13px;
49
- margin-left: 8px;
20
+ font: inherit; font-weight: 600; margin-top: 16px;
21
+ padding: 9px 16px; border-radius: 10px; cursor: pointer;
22
+ color: #fff; background: #2b7cff; border: 1px solid #2b7cff;
50
23
  }
24
+ button:hover { background: #1e6ef0; }
25
+ .hint { color: #6a6d75; font-size: 13px; line-height: 1.6; margin-top: 18px; }
26
+ code { background: #f0f1f4; padding: 2px 6px; border-radius: 6px; font: 12.5px ui-monospace, monospace; }
27
+ #saved { color: #178048; font-size: 13px; margin-left: 10px; }
51
28
  </style>
52
29
  </head>
53
30
  <body>
54
- <h1>OpenCode Annotation settings</h1>
31
+ <h1>OpenCode Annotation</h1>
32
+ <p class="sub">Send element annotations to your OpenCode agent. Text and metadata only.</p>
33
+
55
34
  <label for="endpoint">Plugin endpoint</label>
56
35
  <input id="endpoint" type="text" placeholder="http://127.0.0.1:39517" />
57
- <p>
58
- The URL of the plugin's HTTP server. When OpenCode runs on a remote host,
59
- forward the port with a reverse tunnel and point this at the local end:
60
- <br />
61
- <code>ssh -N -R 39517:127.0.0.1:39517 you@server</code>
62
- </p>
63
36
  <button id="save">Save</button>
64
37
  <span id="saved"></span>
38
+
39
+ <div class="hint">
40
+ <p><strong>Shortcut:</strong> press <code>Alt+A</code> on any page to toggle the annotation overlay.</p>
41
+ <p>When OpenCode runs on a remote host, forward the port from your desktop and point this at the local end:</p>
42
+ <p><code>ssh -N -L 39517:127.0.0.1:39517 you@host</code></p>
43
+ </div>
65
44
  <script src="options.js"></script>
66
45
  </body>
67
46
  </html>