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 +16 -0
- package/README.md +83 -11
- package/bridge/native-host.mjs +29 -0
- package/bridge/receiver.mjs +77 -0
- package/bridge/wire.mjs +38 -0
- package/browser/UPSTREAM.md +9 -0
- package/browser/capture.js +96 -0
- package/browser/icons/icon-128.png +0 -0
- package/browser/icons/icon-16.png +0 -0
- package/browser/icons/icon-32.png +0 -0
- package/browser/icons/icon-48.png +0 -0
- package/browser/icons/icon.svg +3 -0
- package/browser/inspector.js +125 -0
- package/browser/manifest.json +13 -0
- package/browser/panel.css +3 -0
- package/browser/panel.html +23 -0
- package/browser/panel.mjs +155 -0
- package/browser/protocol.mjs +45 -0
- package/browser/upstream/LICENSE +21 -0
- package/browser/upstream/annotate-1.0.0.js +1910 -0
- package/browser/worker.mjs +105 -0
- package/package.json +15 -3
- package/review.mjs +11 -4
- package/scripts/install-bridge.mjs +27 -0
- package/scripts/install-plugin.mjs +34 -0
- package/scripts/migrate-page-context.mjs +31 -0
- package/targets.mjs +32 -0
- package/tui.ts +7 -7
- package/web-review.mjs +36 -0
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
|
-
#
|
|
1
|
+
# OpenCode Annotate
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/opencode-plannotator-tui)
|
|
4
4
|
[](https://github.com/nxxxsooo/opencode-plannotator-tui/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
**Annotate
|
|
7
|
+
**Annotate replies, documents, or live HTML pages, then send feedback to the originating OpenCode session.**
|
|
8
8
|
|
|
9
|
-
This is an **unofficial OpenCode
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/bridge/wire.mjs
ADDED
|
@@ -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 & 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>
|