opencode-plannotator-tui 0.3.0 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2 — OpenCode Annotate
4
+
5
+ The project is now called OpenCode Annotate. It is still published as
6
+ `opencode-plannotator-tui`, because the npm name `opencode-annotate` belongs to an
7
+ unrelated package.
8
+
9
+ - Fix `/annotate` on OpenCode 2.1.1: the terminal stayed blank and Plannotator TUI
10
+ never opened, so Ctrl+C then quit the OpenCode client. The plugin now waits for
11
+ the renderer to suspend and resume.
12
+ - Add webpage review: `/annotate page.html`, `/annotate https://…`, `/annotate web`
13
+ and `/annotate web off`, with an Annotate HTML-derived Chrome companion and
14
+ exact-session Native Messaging delivery. Text review is unchanged.
15
+ - The browser companion shows live sessions automatically, preselects a sole target
16
+ without sending, saves the current note on **Send & continue**, keeps drafts per URL,
17
+ and filters private values and hidden text from exported context.
18
+
3
19
  ## 0.3.0
4
20
 
5
21
  - Select older completed replies with `/annotate 2`, `/annotate 3`, and so on.
package/README.md CHANGED
@@ -1,15 +1,18 @@
1
- # Plannotator TUI for OpenCode
1
+ # OpenCode Annotate
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/opencode-plannotator-tui)](https://www.npmjs.com/package/opencode-plannotator-tui)
4
4
  [![CI](https://github.com/nxxxsooo/opencode-plannotator-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/nxxxsooo/opencode-plannotator-tui/actions/workflows/ci.yml)
5
5
  [![MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
6
 
7
- **Annotate an AI reply or a document in your terminal, then send the feedback straight back to OpenCode.**
7
+ **Annotate replies, documents, or live HTML pages, then send feedback to the originating OpenCode session.**
8
8
 
9
- This is an **unofficial OpenCode adapter** for [Plannotator TUI](https://github.com/plannotator/plannotator-tui).
10
- Plannotator TUI provides the annotation interface. This plugin supplies the commands,
11
- session targeting, terminal handoff and confirmed feedback delivery. It is not an
12
- official OpenCode or Plannotator release.
9
+ This is an **unofficial OpenCode plugin**. [Plannotator TUI](https://github.com/plannotator/plannotator-tui)
10
+ provides text review; the [Annotate HTML-derived browser companion](browser/UPSTREAM.md)
11
+ provides webpage review. This repository owns explicit routing, native messaging,
12
+ session targeting, terminal handoff and confirmed feedback delivery.
13
+
14
+ The npm package is `opencode-plannotator-tui`; the name `opencode-annotate` on npm
15
+ belongs to an unrelated project.
13
16
 
14
17
  ```text
15
18
  /annotate Review the last completed assistant reply
@@ -18,6 +21,10 @@ official OpenCode or Plannotator release.
18
21
  /annotate docs/plan.md Review a Markdown document
19
22
  /annotate "docs/release plan.md" Review a path containing spaces
20
23
  /annotate ~/Documents/notes.txt Review a plain-text document
24
+ /annotate page.html Review the specified local HTML in Chrome
25
+ /annotate https://example.com Review the specified webpage
26
+ /annotate web Open this session for browser pairing
27
+ /annotate web off Revoke this session's browser pairing
21
28
  ```
22
29
 
23
30
  `/annotate-last` always reviews the latest completed reply; any supplied argument is ignored.
@@ -25,7 +32,7 @@ official OpenCode or Plannotator release.
25
32
  ## Install
26
33
 
27
34
  Requires **OpenCode 2.0.22+** and a separately installed `plannotator-tui` on your PATH.
28
- Tested with OpenCode 2.0.24 and Plannotator TUI 0.9.4 on macOS; unit tests also run on
35
+ Tested with OpenCode 2.1.1 and Plannotator TUI 0.9.4 on macOS; unit tests also run on
29
36
  Linux. Windows terminal handoff has not been verified.
30
37
 
31
38
  Install the upstream TUI on macOS or Linux:
@@ -38,7 +45,7 @@ Homebrew 6 may first require `brew trust plannotator/tap`. Alternatively, use
38
45
  `cargo install plannotator-tui --locked` or the upstream
39
46
  [prebuilt binaries](https://github.com/plannotator/plannotator-tui/releases).
40
47
 
41
- Then install the adapter:
48
+ Then install the plugin:
42
49
 
43
50
  ```sh
44
51
  opencode plugin add opencode-plannotator-tui@latest
@@ -56,6 +63,57 @@ Manual configuration, preserving other entries:
56
63
  }
57
64
  ```
58
65
 
66
+ ## Browser review (optional)
67
+
68
+ Requirements: macOS and Chrome 116+, plus Node 22+ for the native host.
69
+
70
+ 1. Clone this repository and load its `browser/` directory as an unpacked extension in
71
+ `chrome://extensions`. Allow file URL access if reviewing local HTML.
72
+ 2. Run `npm run install:bridge` in the clone to register the native host. It allows only this
73
+ companion's fixed extension ID and resolves Node absolutely. The host starts on demand and
74
+ opens no HTTP port.
75
+ 3. Reopen the OpenCode terminal client; the shared service needs no restart.
76
+
77
+ Run `/annotate web` in a target session, then open the Chrome extension. A sole live
78
+ session is shown automatically; with several sessions, choose one once. **Change**
79
+ opens the optional session selector. The target title is always visible, with its directory
80
+ available on hover. A sole target is only preselected: no binding or model turn occurs until
81
+ the user clicks **Send & continue**. A disconnected destination never silently switches to another session.
82
+
83
+ Pick an element or select text, write feedback, then click **Send & continue**. It saves
84
+ the current feedback and sends it in one action; **Save note** remains available for batching.
85
+ This sends pending notes to that captured
86
+ session and starts a normal model turn; pairing and saving notes make no model call.
87
+ Switching terminal tabs never changes the paired target. Revoke with `/annotate web off`.
88
+
89
+ ### Explicit routing
90
+
91
+ No argument or a reply number always means TUI. Markdown/plain text files also use TUI.
92
+ Only explicit `.html`/`.htm` paths, HTTP/HTTPS URLs, HTML file URLs, or `web` enter the browser
93
+ workflow. A reply containing HTML links or code fences does not change the default.
94
+
95
+ ### Browser safeguards and limitations
96
+
97
+ Saved notes survive reloads and are isolated by URL, including SPA routes. Per-note revision
98
+ checks prevent stale tabs overwriting newer feedback. Full JSON/Markdown exports include
99
+ redacted URLs, exact selected text/context, verified selector hints, styles and accessibility.
100
+ Missing/changed/ambiguous targets are labeled in the sidebar instead of silently rebound.
101
+ Comments and drafts live in extension-owned UI/storage; the page receives only selection
102
+ metadata and numbered markers, not your written feedback.
103
+
104
+ Native delivery requires an open, paired local TUI. If the receiver closes, feedback stays
105
+ pending. A failed batch retains its original target and ID for safe retry; if that target
106
+ is permanently gone, export the notes before starting a new review. Sent-note edits become
107
+ new revisions. Clipboard failures expose a selectable preview. Browser-internal pages, PDF,
108
+ cross-origin iframes, Canvas and remote SSH TUI clients are outside this version's scope.
109
+ Page Context is retired only after exporting its drafts and verifying this replacement.
110
+
111
+ See [implementation plan](docs/annotate-html-integration-plan.md) and
112
+ [shipped-artifact evaluation](docs/annotate-html-evaluation.md), plus
113
+ [browser provenance](browser/UPSTREAM.md). `npm test`, `npm run test:smoke`, and
114
+ `npm run test:browser` verify each layer; the browser test uses an existing Playwright installation
115
+ (`PLAYWRIGHT_MODULE`) and browser executable (`CHROMIUM_PATH`) in an isolated profile.
116
+
59
117
  ## Review and send
60
118
 
61
119
  1. Run `/annotate` or `/annotate path/to/file.md` in a session.
@@ -124,6 +182,13 @@ Set `binary` if Plannotator TUI is installed outside the terminal client's PATH:
124
182
  }
125
183
  ```
126
184
 
185
+ The adapter launches Plannotator TUI in the current terminal, not through Herdr;
186
+ `[herdr]` placement and popup dimensions do not affect `/annotate`. Layout is
187
+ owned by the selected binary, while font family and size belong to the terminal.
188
+ The adapter supplies `PLANNOTATOR_TUI_THEME` to follow OpenCode's light/dark mode,
189
+ overriding Plannotator TUI's configured theme. Use `binary` to select a custom
190
+ build or launcher without replacing the system installation.
191
+
127
192
  ## Draft recovery
128
193
 
129
194
  Each review has its own private temporary source and annotation store. Successful,
@@ -155,19 +220,26 @@ also requires Python 3, tmux and OpenCode. It runs a detached terminal, verifies
155
220
  commands and feedback through the actual API, then removes its test session.
156
221
  Its submission uses `resume: false`, so it makes no model call or visible window.
157
222
 
158
- For development loading, add this checkout's absolute directory to `cli.json`
159
- instead of the npm package. Use only one installation at a time.
223
+ Set `ANNOTATE_TUI_BINARY` to test a custom binary or launcher through the plugin's
224
+ real configuration path. For a build with a centered 120-column viewport, also
225
+ set `ANNOTATE_EXPECT_MARGIN=10` to assert blank margins in the 140-column smoke
226
+ terminal. These variables configure the test, not the shipped plugin.
227
+
228
+ For development loading, run `npm run install:plugin` to replace the npm entry in
229
+ `cli.json` with this checkout's absolute directory. It backs up the CLI preferences and
230
+ keeps `options.binary` and unrelated settings. Use only one installation at a time.
160
231
 
161
232
  See [release instructions](docs/releasing.md) and [changelog](CHANGELOG.md).
162
233
 
163
234
  ## 中文速用
164
235
 
165
- 这是 **Plannotator TUI 的非官方 OpenCode 适配插件**。
236
+ 这是**非官方 OpenCode 批注插件**:文本用 Plannotator TUI,网页用 Chrome 伴随扩展。
166
237
 
167
238
  - `/annotate`:批注上一条完整回复。
168
239
  - `/annotate 2`:批注倒数第二条,`3`、`4` 依次往前;`1` 等同于默认。
169
240
  - `/annotate 文档.md`:批注文档,支持中文、空格和相对路径。
170
241
  - `/annotate-last`:固定批注最新一条完整回复,忽略附带参数。
242
+ - `/annotate 页面.html`、`/annotate https://…`、`/annotate web`:在 Chrome 中批注网页;`/annotate web off` 撤销配对。
171
243
  - 选中文字 → `c` 写批注 → Enter 保存 → `q` 返回 →「Send and continue」。
172
244
  - 文档采用独立快照,反馈附原文件路径,确认后发回发起批注的会话。
173
245
  - 更新:`opencode plugin update opencode-plannotator-tui@latest`,然后重新打开终端客户端。
@@ -0,0 +1,29 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { decodeStream, encode, request } from "./wire.mjs"
3
+ import { listTargets } from "./receiver.mjs"
4
+
5
+ const configPath = process.argv[2]
6
+ const origin = process.argv[3]
7
+ const config = JSON.parse(await readFile(configPath, "utf8"))
8
+ if (origin !== `chrome-extension://${config.extensionID}/`) throw new Error("Unapproved extension origin")
9
+ let handled = false
10
+ const timeout = setTimeout(() => process.exit(1), 20000)
11
+ decodeStream(process.stdin, async message => {
12
+ if (handled) return
13
+ handled = true; process.stdin.pause()
14
+ let response
15
+ try {
16
+ const targets = await listTargets(config.targetsDirectory)
17
+ if (message.action === "targets") {
18
+ const live = await Promise.all(targets.map(async target => {
19
+ try { return (await request(target.socket, { action: "info", targetID: target.id })).target } catch { return null }
20
+ }))
21
+ response = { ok: true, targets: live.filter(Boolean) }
22
+ } else {
23
+ const target = targets.find(target => target.id === message.targetID)
24
+ if (!target) throw new Error("Paired session is offline; run /annotate web and pair again")
25
+ response = await request(target.socket, message)
26
+ }
27
+ } catch (error) { response = { ok: false, error: error.message } }
28
+ clearTimeout(timeout); process.stdout.end(encode(response))
29
+ }, error => { clearTimeout(timeout); process.stdout.end(encode({ ok: false, error: error.message })) })
@@ -0,0 +1,77 @@
1
+ import net from "node:net"
2
+ import { randomUUID, createHash } from "node:crypto"
3
+ import { mkdir, chmod, writeFile, readdir, readFile, rm, lstat } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { encode, decodeStream } from "./wire.mjs"
7
+ import { markdown, validateReview } from "../browser/protocol.mjs"
8
+
9
+ export const registryRoot = () => path.join(os.homedir(), ".local/share/opencode-annotate/targets")
10
+ export async function listTargets(root = registryRoot()) {
11
+ let files
12
+ try { files = await readdir(root) } catch (error) { if (error.code === "ENOENT") return []; throw error }
13
+ const results = []
14
+ for (const file of files.filter(name => /^[a-f0-9-]+\.json$/.test(name))) {
15
+ const location = path.join(root, file)
16
+ const info = await lstat(location)
17
+ if (info.isSymbolicLink() || (info.mode & 0o077) || info.uid !== process.getuid() || info.size > 16384) continue
18
+ try {
19
+ const value = JSON.parse(await readFile(location, "utf8"))
20
+ if (typeof value.id !== "string" || file !== `${value.id}.json` || value.socket !== path.join(root, `${value.id}.sock`)) continue
21
+ try { process.kill(value.pid, 0) } catch { continue }
22
+ results.push(value)
23
+ } catch { /* stale/incomplete registration is not a target */ }
24
+ }
25
+ return results
26
+ }
27
+ export async function createReceiver({ sessionID, title, directory, client, root = registryRoot(), resume = true, onError = () => {} }) {
28
+ await mkdir(root, { recursive: true, mode: 0o700 }); await chmod(root, 0o700)
29
+ const id = randomUUID(), socketPath = path.join(root, `${id}.sock`)
30
+ // macOS sockaddr_un is limited to 104 bytes, including the NUL terminator.
31
+ if (Buffer.byteLength(socketPath) > 103) throw new Error("Annotation socket path is too long")
32
+ const target = { id, title, directory, sessionID, socket: socketPath, pid: process.pid }
33
+ const received = new Map(), sockets = new Set()
34
+ let closed = false
35
+ const server = net.createServer(socket => {
36
+ let handled = false
37
+ sockets.add(socket); socket.once("close", () => sockets.delete(socket)); socket.on("error", () => {})
38
+ socket.setTimeout(20000, () => socket.destroy())
39
+ decodeStream(socket, async message => {
40
+ if (handled) return
41
+ handled = true
42
+ try {
43
+ if (closed || message.targetID !== id) throw new Error("Pairing is no longer active")
44
+ if (message.action === "info") { await client.session.get({ sessionID }); socket.end(encode({ ok: true, target: { id, title, directory } })); return }
45
+ if (message.action !== "send" || !/^[a-f0-9-]{36}$/.test(message.batchID)) throw new Error("Invalid delivery request")
46
+ validateReview(message.review)
47
+ const digest = createHash("sha256").update(JSON.stringify(message.review)).digest("hex")
48
+ const existing = received.get(message.batchID)
49
+ if (existing && existing.digest !== digest) throw new Error("Batch ID was reused with different content")
50
+ if (!existing) {
51
+ // Capture immutable input before awaiting; simultaneous retries share one admission promise.
52
+ const promptID = `msg_${createHash("sha256").update(`${id}:${message.batchID}`).digest("hex").slice(0, 32)}`
53
+ const promise = (async () => {
54
+ await client.session.get({ sessionID })
55
+ const response = await client.session.prompt({ sessionID, id: promptID, text: markdown(message.review), delivery: "queue", resume })
56
+ return { ok: true, messageID: response?.id || promptID, state: "admitted" }
57
+ })()
58
+ received.set(message.batchID, { digest, promise })
59
+ promise.catch(() => received.delete(message.batchID))
60
+ }
61
+ socket.end(encode(await received.get(message.batchID).promise))
62
+ } catch (error) { if (!socket.destroyed) socket.end(encode({ ok: false, error: error.message })) }
63
+ }, () => socket.destroy())
64
+ })
65
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve) })
66
+ server.on("error", onError)
67
+ await chmod(socketPath, 0o600)
68
+ const registration = path.join(root, `${id}.json`)
69
+ try { await writeFile(registration, JSON.stringify(target), { mode: 0o600, flag: "wx" }) }
70
+ catch (error) { await new Promise(resolve => server.close(resolve)); await rm(socketPath, { force: true }); throw error }
71
+ return { target, async close() {
72
+ if (closed) return
73
+ closed = true; for (const socket of sockets) socket.destroy()
74
+ await new Promise(resolve => server.close(resolve))
75
+ await rm(registration, { force: true }); await rm(socketPath, { force: true })
76
+ } }
77
+ }
@@ -0,0 +1,38 @@
1
+ import net from "node:net"
2
+ import { MAX_BYTES } from "../browser/protocol.mjs"
3
+
4
+ export function encode(value) {
5
+ const body = Buffer.from(JSON.stringify(value))
6
+ if (body.length > MAX_BYTES) throw new Error("Message exceeds 512 KiB")
7
+ const header = Buffer.alloc(4); header.writeUInt32LE(body.length)
8
+ return Buffer.concat([header, body])
9
+ }
10
+ export function decodeStream(stream, onMessage, onError) {
11
+ let buffer = Buffer.alloc(0), stopped = false
12
+ const data = chunk => {
13
+ if (stopped) return
14
+ try {
15
+ buffer = Buffer.concat([buffer, chunk])
16
+ while (buffer.length >= 4) {
17
+ const size = buffer.readUInt32LE(0)
18
+ if (!size || size > MAX_BYTES) throw new Error("Invalid frame size")
19
+ if (buffer.length < size + 4) break
20
+ const value = JSON.parse(buffer.subarray(4, size + 4).toString("utf8"))
21
+ buffer = buffer.subarray(size + 4); onMessage(value)
22
+ }
23
+ } catch (error) { stopped = true; onError(error) }
24
+ }
25
+ stream.on("data", data)
26
+ return () => { stopped = true; stream.off("data", data) }
27
+ }
28
+ export function request(socketPath, value) {
29
+ return new Promise((resolve, reject) => {
30
+ const socket = net.createConnection(socketPath)
31
+ const done = (error, result) => { stop(); socket.destroy(); error ? reject(error) : resolve(result) }
32
+ const stop = decodeStream(socket, result => done(null, result), error => done(error))
33
+ socket.setTimeout(15000, () => done(new Error("Annotation receiver timed out")))
34
+ socket.once("error", error => done(error))
35
+ socket.once("end", () => done(new Error("Annotation receiver disconnected")))
36
+ socket.once("connect", () => { try { socket.write(encode(value)) } catch (error) { done(error) } })
37
+ })
38
+ }
@@ -0,0 +1,9 @@
1
+ # Annotate HTML-derived browser companion
2
+
3
+ Based on Asim Sayed's MIT-licensed [Annotate HTML](https://github.com/AsimSayed/annotate-html), Chrome Web Store release 1.0.0 (extension ID `ageaediaogfbmbmjoamkcloagepjcgdp`).
4
+
5
+ `upstream/annotate-1.0.0.js` preserves the unmodified released JavaScript, SHA-256 `ae30233d0db847c21ced822c63ec5f0ead1c6f361518ec28a21ea49defcd1206`; its MIT license is adjacent. Icons are preserved from that release. The original implementation credits Agentation for its element-identification approach.
6
+
7
+ `inspector.js` adapts the original element identification, computed-style snapshot, and accessibility collectors. This local enhancement replaces the in-page comment popup and volatile state with an extension-origin side panel, durable per-note storage, exact range context and validated selectors. It adds complete export and opt-in paired session delivery. The original upstream implementation is not injected alongside the enhanced runtime.
8
+
9
+ This is a distinct local extension with its own ID, not an official Annotate HTML update. It has no upstream update URL, analytics or external data service. Only user-requested Native Messaging can send feedback to a paired local OpenCode client. Native Messaging and side-panel installation are macOS/Chrome-only in this version.
@@ -0,0 +1,96 @@
1
+ (() => {
2
+ if (globalThis.__opencodeAnnotate === true) throw new Error("Refresh this page once to update annotation tools. Saved notes will remain.")
3
+ globalThis.__opencodeAnnotate?.dispose()
4
+ if (!["text/html", "application/xhtml+xml"].includes(document.contentType)) throw new Error("Only HTML pages are supported")
5
+ const listeners = new AbortController()
6
+ const on = (target, event, handler, options = {}) => target.addEventListener(event, handler, { ...options, signal: listeners.signal })
7
+ const host = document.createElement("div")
8
+ host.setAttribute("data-opencode-annotate", "")
9
+ host.style.cssText = "all:initial!important;position:fixed!important;inset:0!important;pointer-events:none!important;z-index:2147483647!important"
10
+ const root = host.attachShadow({ mode: "closed" })
11
+ const style = document.createElement("style")
12
+ style.textContent = '.box{position:fixed;pointer-events:none;border:2px solid #c96442;background:#c9644210}.pin{position:fixed;background:#c96442;color:#fff;border:2px solid #fff;border-radius:50%;font:600 12px system-ui;width:26px;height:26px;pointer-events:none}'
13
+ root.append(style); document.documentElement.append(host)
14
+ const box = document.createElement("div"); box.className = "box"
15
+ let picking = false, url = location.href, timer, markers = [], active = true
16
+ function send(anchor = null, error = null, modeOnly = false) {
17
+ if (!active) return
18
+ try {
19
+ chrome.runtime.sendMessage({ action: "capture", page: { url: location.href, title: document.title }, anchor, error, picking, modeOnly }).catch(dispose)
20
+ } catch { dispose() }
21
+ }
22
+ function setPicking(value, notify = true) {
23
+ picking = value
24
+ if (picking) root.append(box); else box.remove()
25
+ if (notify) send(null, null, true)
26
+ }
27
+ function selection() {
28
+ if (picking || !active) return
29
+ const selected = getSelection()
30
+ if (!selected?.rangeCount || selected.isCollapsed || selected.anchorNode?.getRootNode() !== document) return
31
+ const range = selected.getRangeAt(0).cloneRange()
32
+ let element = range.commonAncestorContainer
33
+ if (element.nodeType !== 1) element = element.parentElement
34
+ try { send(AnnotateInspector.capture(element, range)) } catch (error) { send(null, error.message) }
35
+ }
36
+ on(document, "selectionchange", () => { clearTimeout(timer); timer = setTimeout(selection, 160) })
37
+ on(document, "pointerup", selection)
38
+ on(document, "mousemove", event => {
39
+ if (!picking) return
40
+ const rect = event.target.getBoundingClientRect()
41
+ box.style.cssText = `left:${rect.x}px;top:${rect.y}px;width:${rect.width}px;height:${rect.height}px`
42
+ }, { capture: true })
43
+ on(document, "pointerdown", event => { if (picking) { event.preventDefault(); event.stopImmediatePropagation() } }, { capture: true })
44
+ on(document, "click", event => {
45
+ if (!picking) return
46
+ event.preventDefault(); event.stopImmediatePropagation()
47
+ // A failed pick must never leave the page trapped in interception mode.
48
+ setPicking(false, false)
49
+ try { send(AnnotateInspector.capture(event.target)) } catch (error) { send(null, error.message) }
50
+ }, { capture: true })
51
+ on(document, "keydown", event => { if (picking && event.key === "Escape") setPicking(false) })
52
+ const onMessage = (message, _sender, respond) => {
53
+ if (message.action === "pick") { setPicking(!picking); respond({ ok: true, picking }) }
54
+ if (message.action === "markers") { markers = message.notes || []; draw(); respond({ ok: true }) }
55
+ if (message.action === "locate") respond({ states: message.anchors.map(locate) })
56
+ }
57
+ chrome.runtime.onMessage.addListener(onMessage)
58
+ function locate(anchor) {
59
+ try {
60
+ const matches = document.querySelectorAll(anchor.selector)
61
+ if (matches.length !== 1) return matches.length ? "ambiguous" : "missing"
62
+ const text = AnnotateInspector.text(matches[0])
63
+ if (!text.includes(anchor.quote)) return "changed"
64
+ if (anchor.type === "text" && text.split(anchor.quote).length > 2) {
65
+ if (!text.includes(`${anchor.prefix || ""}${anchor.quote}${anchor.suffix || ""}`)) return "ambiguous"
66
+ }
67
+ return "located"
68
+ } catch { return "invalid selector" }
69
+ }
70
+ function draw() {
71
+ root.querySelectorAll(".pin").forEach(node => node.remove())
72
+ for (const [index, note] of markers.entries()) {
73
+ let matches
74
+ try { matches = document.querySelectorAll(note.anchor.selector) } catch { continue }
75
+ if (matches.length !== 1) continue
76
+ const element = matches[0]
77
+ // A changed or removed quote is an orphan, not another equally named element.
78
+ if (!AnnotateInspector.text(element).includes(note.anchor.quote)) continue
79
+ const rect = element.getBoundingClientRect()
80
+ const pin = document.createElement("span"); pin.className = "pin"; pin.textContent = String(index + 1)
81
+ pin.style.cssText = `left:${rect.left - 10}px;top:${rect.top - 10}px`; root.append(pin)
82
+ }
83
+ }
84
+ on(window, "scroll", draw, { passive: true }); on(window, "resize", draw)
85
+ const observer = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(draw, 160) })
86
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
87
+ const routeTimer = setInterval(() => { if (!active) { observer.disconnect(); return } if (url !== location.href) { url = location.href; markers = []; draw(); send() } }, 700)
88
+ function dispose() {
89
+ if (!active) return
90
+ active = false; listeners.abort(); clearInterval(routeTimer); clearTimeout(timer); observer.disconnect(); host.remove()
91
+ try { chrome.runtime.onMessage.removeListener(onMessage) } catch { /* previous extension context may already be invalidated */ }
92
+ delete globalThis.__opencodeAnnotate
93
+ }
94
+ globalThis.__opencodeAnnotate = { dispose }
95
+ send()
96
+ })();
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 155 157" width="128" height="128">
2
+ <path d="M58.8282 156.513L62.2248 103.934L18.2055 133.416L0 101.353L47.4158 78.2565L0 55.1599L18.2055 23.0965L62.2248 52.5786L58.8282 0H95.3751L91.8427 52.5786L135.862 23.0965L154.067 55.1599L106.787 78.2565L154.067 101.353L135.862 133.416L91.8427 103.934L95.3751 156.513H58.8282Z" fill="#C96442"/>
3
+ </svg>
@@ -0,0 +1,125 @@
1
+ // Derived from AsimSayed/annotate-html 1.0.0, MIT. Original bytes and license in upstream/.
2
+ (() => {
3
+ function identifyElement(target, text) {
4
+ const tag = target.tagName.toLowerCase()
5
+ if (target.dataset?.element) return target.dataset.element
6
+ if (["path", "circle", "rect", "line", "g", "svg"].includes(tag)) return "graphic element"
7
+ const label = target.getAttribute("aria-label") || target.getAttribute("alt") || text || ""
8
+ if (tag === "input") return `input "${target.getAttribute("placeholder") || target.getAttribute("type") || "text"}"`
9
+ return label ? `${tag} "${label.slice(0, 60)}"` : tag
10
+ }
11
+ function getComputedSnapshot(el) {
12
+ var s = window.getComputedStyle(el)
13
+ var parts = []
14
+ if (s.color && s.color !== "rgb(0, 0, 0)") parts.push("color: " + s.color)
15
+ var bg = s.backgroundColor
16
+ if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") parts.push("bg: " + bg)
17
+ if (s.fontSize) parts.push("font: " + s.fontSize)
18
+ if (s.fontWeight && s.fontWeight !== "400" && s.fontWeight !== "normal") parts.push("weight: " + s.fontWeight)
19
+ if (s.padding && s.padding !== "0px") parts.push("padding: " + s.padding)
20
+ if (s.margin && s.margin !== "0px") parts.push("margin: " + s.margin)
21
+ var d = s.display
22
+ if (d && d !== "block" && d !== "inline") parts.push("display: " + d)
23
+ if (s.position && s.position !== "static") parts.push("position: " + s.position)
24
+ if (s.borderRadius && s.borderRadius !== "0px") parts.push("radius: " + s.borderRadius)
25
+ return parts.join(", ")
26
+ }
27
+ function getA11yInfo(el) {
28
+ var parts = []
29
+ var role = el.getAttribute("role")
30
+ var ariaLabel = el.getAttribute("aria-label")
31
+ var tabIndex = el.getAttribute("tabindex")
32
+ var ariaHidden = el.getAttribute("aria-hidden")
33
+ if (role) parts.push('role="' + role + '"')
34
+ if (ariaLabel) parts.push('aria-label="' + ariaLabel + '"')
35
+ if (tabIndex) parts.push("tabindex=" + tabIndex)
36
+ if (ariaHidden === "true") parts.push("aria-hidden")
37
+ if (el.matches("a, button, input, select, textarea, [tabindex]")) parts.push("focusable")
38
+ return parts.join(", ")
39
+ }
40
+ function selector(element) {
41
+ const parts = []
42
+ for (let node = element; node?.nodeType === 1; node = node.parentElement) {
43
+ if (node.id && document.querySelectorAll(`#${CSS.escape(node.id)}`).length === 1) { parts.unshift(`#${CSS.escape(node.id)}`); break }
44
+ const siblings = node.parentElement ? [...node.parentElement.children].filter(x => x.localName === node.localName) : [node]
45
+ parts.unshift(`${CSS.escape(node.localName)}:nth-of-type(${siblings.indexOf(node) + 1})`)
46
+ }
47
+ const result = parts.join(" > ")
48
+ if (document.querySelectorAll(result).length !== 1) throw new Error("Element cannot be uniquely located")
49
+ return result
50
+ }
51
+ const editable = 'input,textarea,select,[contenteditable]:not([contenteditable="false"])'
52
+ const excluded = `${editable},script,style,noscript,template,[hidden],[aria-hidden="true"]`
53
+ function textFilter() {
54
+ const cache = new WeakMap()
55
+ return function omitted(element) {
56
+ if (!element) return false
57
+ if (cache.has(element)) return cache.get(element)
58
+ const style = getComputedStyle(element)
59
+ const hidden = element.matches(excluded) || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.opacity === "0" || omitted(element.parentElement)
60
+ cache.set(element, hidden)
61
+ return hidden
62
+ }
63
+ }
64
+ function safeText(range, omitted) {
65
+ const root = range.commonAncestorContainer
66
+ const nodes = []
67
+ if (root.nodeType === Node.TEXT_NODE) nodes.push(root)
68
+ else {
69
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
70
+ let node
71
+ while ((node = walker.nextNode())) nodes.push(node)
72
+ }
73
+ const pieces = []
74
+ for (const node of nodes) {
75
+ if (!range.intersectsNode(node) || omitted(node.parentElement)) continue
76
+ const start = node === range.startContainer ? range.startOffset : 0
77
+ const end = node === range.endContainer ? range.endOffset : node.length
78
+ pieces.push(node.data.slice(start, end))
79
+ }
80
+ return pieces.join("")
81
+ }
82
+ function elementText(element, omitted = textFilter()) {
83
+ const control = element.closest(editable)
84
+ if (control) return control.getAttribute("aria-label") || control.getAttribute("placeholder") || `${control.getAttribute("type") || control.localName} control`
85
+ const contents = document.createRange(); contents.selectNodeContents(element)
86
+ return safeText(contents, omitted).trim() || element.getAttribute("aria-label") || element.getAttribute("alt") || element.localName
87
+ }
88
+ function capture(element, range) {
89
+ if (!element || element.getRootNode() !== document) throw new Error("Select content in the main page")
90
+ // Decorative SVGs commonly carry aria-hidden. Point to their visible control instead.
91
+ if (!range && element.closest("[aria-hidden=true]")) element = element.closest('button,a,[role="button"]') || element.parentElement
92
+ if (!element) throw new Error("Choose a visible page element")
93
+ const style = getComputedStyle(element)
94
+ if (element.closest("[hidden],script,style,noscript,template") || style.display === "none" || style.visibility === "hidden") throw new Error("Choose a visible page element")
95
+ if (["iframe", "canvas", "embed", "object"].includes(element.localName)) throw new Error("This content type is unsupported")
96
+ const omitted = textFilter()
97
+ const contents = document.createRange(); contents.selectNodeContents(element)
98
+ const visibleText = safeText(contents, omitted).trim()
99
+ const container = element.closest("p,li,blockquote,pre,td,th,section,article,main") || element
100
+ let quote = "", prefix = "", suffix = ""
101
+ if (range) {
102
+ quote = safeText(range, omitted)
103
+ if (!quote.trim()) throw new Error("Editable values are private. Use Pick an element to annotate the control itself.")
104
+ const before = document.createRange(); before.selectNodeContents(container); before.setEnd(range.startContainer, range.startOffset)
105
+ const after = document.createRange(); after.selectNodeContents(container); after.setStart(range.endContainer, range.endOffset)
106
+ prefix = [...safeText(before, omitted)].slice(-160).join(""); suffix = [...safeText(after, omitted)].slice(0, 160).join("")
107
+ } else {
108
+ element = element.closest(editable) || element
109
+ quote = elementText(element, omitted)
110
+ for (const [sibling, side] of [[element.previousElementSibling, "before"], [element.nextElementSibling, "after"]]) {
111
+ if (!sibling) continue
112
+ const siblingRange = document.createRange(); siblingRange.selectNodeContents(sibling)
113
+ const text = safeText(siblingRange, omitted)
114
+ if (side === "before") prefix = [...text].slice(-160).join(""); else suffix = [...text].slice(0, 160).join("")
115
+ }
116
+ }
117
+ if (quote.length > 4000) throw new Error("Choose at most 4000 characters")
118
+ const rect = (range || element).getBoundingClientRect()
119
+ return { type: range ? "text" : "element", quote, prefix, suffix, selector: selector(element), fullPath: selector(element),
120
+ element: identifyElement(element, visibleText), styles: getComputedSnapshot(element), accessibility: getA11yInfo(element),
121
+ rect: Object.fromEntries(["x", "y", "width", "height"].map(key => [key, Math.round(rect[key])])),
122
+ viewport: { width: innerWidth, height: innerHeight, scrollX, scrollY } }
123
+ }
124
+ globalThis.AnnotateInspector = { capture, text: elementText }
125
+ })();
@@ -0,0 +1,13 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "OpenCode Annotate",
4
+ "version": "0.4.1",
5
+ "description": "Annotate HTML-based page feedback, private drafts, and paired OpenCode delivery.",
6
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp7TG2yzekNKpQvHBtj4jqkOxtoeDIBhNIjPi37Os/ksuhzPp1pHnrJPV4WV2ToatUaD5x5/SpLP8qZKLBD+wuraEE/TeclEZ8HvEs1yhlnx/S7piTG/s0eV5umuTEx2w66Hi0w3dhTC7ZZfJXCLyqs1feBtWzQx6ClgaHNYly4iJ03pV7RfguqpIaG9LV8Oa8ELA+7Ubr0BAujXrSlvqLF5oAKGoXXcpvTL5rgMRIH4Q/85tnvrk0pRVg+1z9IGNzSfjN82iveC/JAriyWABp8YuswtvKfI2Wb+KspaisBh39Y3BRqlyo8I2Cr+01uE6G5pWiJIWMLhe0glrMr5I2wIDAQAB",
7
+ "permissions": ["activeTab", "scripting", "storage", "sidePanel", "nativeMessaging"],
8
+ "action": { "default_title": "Annotate this page", "default_icon": "icons/icon-32.png" },
9
+ "background": { "service_worker": "worker.mjs", "type": "module" },
10
+ "side_panel": { "default_path": "panel.html" },
11
+ "commands": { "_execute_action": { "suggested_key": { "default": "Alt+Shift+A", "mac": "Alt+Shift+A" } } },
12
+ "icons": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" }
13
+ }
@@ -0,0 +1,3 @@
1
+ :root{color-scheme:light dark;--bg:light-dark(#faf9f5,#20201f);--surface:light-dark(#fff,#292927);--text:light-dark(#27251f,#f2eee5);--muted:light-dark(#625d52,#c2bbae);--border:light-dark(#d4cfc3,#625d52);--accent:light-dark(#944525,#f2ad90)}
2
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,sans-serif}header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--border)}h1{font-size:19px;margin:0}h2{font-size:15px;margin:0 0 8px}main{padding:0 16px 16px}section{padding:16px 0;border-bottom:1px solid var(--border)}p{margin:6px 0 10px;overflow-wrap:anywhere}button,select,textarea{font:inherit;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--text)}button{padding:8px 11px;cursor:pointer;min-height:36px}button:hover{border-color:var(--accent)}button.primary{background:var(--accent);color:light-dark(#fff,#29201b);border-color:var(--accent);font-weight:600}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}label{display:block;margin:10px 0 5px;font-weight:600}textarea,select{width:100%;padding:8px;caret-color:var(--accent)}textarea{resize:vertical;min-height:90px}textarea::placeholder{color:var(--muted)}.actions{display:flex;gap:8px;margin-top:8px}.actions>*{flex:1}.hint,#page-url,#paired,#status{color:var(--muted);font-size:12px}blockquote{margin:12px 0;padding:10px;background:var(--surface);border:1px solid var(--border);border-radius:5px;max-height:120px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}ol{list-style:none;margin:0;padding:0}li{padding:12px 0;border-top:1px solid var(--border)}li:first-child{border-top:0}li p{white-space:pre-wrap}li small{color:var(--muted);overflow-wrap:anywhere}li .actions button{flex:0 0 auto;padding:5px 9px}footer{position:sticky;bottom:0;padding:12px 16px;background:var(--bg);border-top:1px solid var(--border)}#send{width:100%}#status{min-height:18px;margin-bottom:0}#status.error{color:light-dark(#a12929,#ffb3ac)}pre{font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto}details{margin-top:14px}summary{cursor:pointer}::selection{background:var(--accent);color:var(--bg)}*{scrollbar-color:var(--border) var(--bg)}
3
+ :root .connection-row{display:flex;align-items:center;gap:12px}.connection-row h2{flex:1;margin:0}.connection-row button{padding:4px 9px;min-height:30px}#paired{color:var(--text);font-size:14px;margin-top:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#connection-hint{margin-bottom:0}#session-options{margin-top:10px}#refresh-targets{margin-top:8px}#pick[aria-pressed=true]{border-color:var(--accent);background:var(--accent);color:light-dark(#fff,#29201b)}
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OpenCode Annotate</title><link rel="stylesheet" href="panel.css"></head>
3
+ <body>
4
+ <header><h1>Annotate</h1><span id="count">0 notes</span></header>
5
+ <main>
6
+ <section aria-label="Target session">
7
+ <div class="connection-row"><h2>Send to</h2><button id="change-target" type="button" aria-expanded="false" aria-controls="session-options">Change</button></div>
8
+ <p id="paired">Looking for your OpenCode session…</p><p id="connection-hint" class="hint" aria-live="polite"></p>
9
+ <div id="session-options" hidden><label for="targets">Choose session</label><select id="targets"><option value="">Select a session…</option></select><button id="refresh-targets" type="button">Refresh sessions</button></div>
10
+ </section>
11
+ <section aria-label="Page annotation"><h2 id="page-title">Open a page</h2><p id="page-url"></p>
12
+ <button id="pick" aria-pressed="false">Pick an element</button><p id="pick-hint" class="hint">Or select text on the page, write feedback, then send. Use Save note to collect several notes first.</p>
13
+ <blockquote id="quote">Select text or an element to begin.</blockquote>
14
+ <label for="kind">Feedback type</label><select id="kind"><option value="comment">Comment</option><option value="ask">Question</option></select>
15
+ <label for="comment">My feedback</label><textarea id="comment" rows="4" maxlength="8000" placeholder="What should change, or what would you like to understand?"></textarea>
16
+ <div class="actions"><button id="save">Save note</button><button id="cancel">Cancel edit</button></div>
17
+ </section>
18
+ <section aria-label="Saved feedback"><h2>Notes</h2><ol id="notes"></ol></section>
19
+ <details><summary>Context preview</summary><pre id="preview"></pre></details>
20
+ </main>
21
+ <footer><button id="send" class="primary" aria-describedby="paired">Send &amp; continue</button><div class="actions"><button id="copy">Copy Markdown</button><button id="export">Export JSON</button></div><p id="status" role="status" aria-live="polite">Ready</p></footer>
22
+ <script type="module" src="panel.mjs"></script>
23
+ </body></html>