opencode-plannotator-tui 0.2.2 → 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.
@@ -0,0 +1,105 @@
1
+ import { HOST, safeUrl, validateReview } from "./protocol.mjs"
2
+
3
+ const pageKey = async url => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(url)))].map(v => v.toString(16).padStart(2, "0")).join("")
4
+ let writes = Promise.resolve()
5
+ const serial = fn => { const next = writes.then(fn); writes = next.catch(() => {}); return next }
6
+ async function activate(tab) {
7
+ if (!tab?.id || !/^(https?|file):/.test(tab.url || "")) throw new Error("Open an ordinary webpage or authorized local HTML")
8
+ await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["inspector.js", "capture.js"] })
9
+ await chrome.storage.session.set({ [`tab:${tab.id}`]: { page: { url: safeUrl(tab.url), title: tab.title || "Page" }, anchor: null } })
10
+ }
11
+ chrome.action.onClicked.addListener(tab => {
12
+ // open() must retain the browser's action user gesture, before asynchronous injection.
13
+ chrome.sidePanel.open({ tabId: tab.id }).catch(console.error)
14
+ activate(tab).catch(async error => { await chrome.storage.session.set({ [`tab:${tab.id}`]: { error: error.message } }) })
15
+ })
16
+ async function current() {
17
+ const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
18
+ if (!tab) throw new Error("No active page")
19
+ const state = (await chrome.storage.session.get(`tab:${tab.id}`))[`tab:${tab.id}`]
20
+ if (!state?.page) throw new Error(state?.error || "Click the extension icon on this page to grant access")
21
+ const hash = await pageKey(state.page.url)
22
+ const storage = await chrome.storage.local.get(null)
23
+ const notes = Object.entries(storage).filter(([key, value]) => key.startsWith(`note:${hash}:`) && !value.deleted).map(([, value]) => value).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
24
+ let anchorStates = notes.map(() => "unavailable")
25
+ try { anchorStates = (await chrome.tabs.sendMessage(tab.id, { action: "locate", anchors: notes.map(note => note.anchor) })).states } catch { /* visibly unavailable while navigating */ }
26
+ return { tabID: tab.id, ...state, notes, anchorStates, hash }
27
+ }
28
+ async function handle(message, sender) {
29
+ if (message.action === "capture") {
30
+ if (!sender.tab || !sender.url || new URL(message.page.url).origin !== new URL(sender.url).origin) throw new Error("Invalid page source")
31
+ const key = `tab:${sender.tab.id}`
32
+ const previous = (await chrome.storage.session.get(key))[key] || {}
33
+ await chrome.storage.session.set({ [key]: { ...previous, page: { url: safeUrl(message.page.url), title: String(message.page.title).slice(0, 500) }, picking: !!message.picking,
34
+ ...(!message.modeOnly ? { anchor: message.anchor, error: message.error } : {}) } })
35
+ return {}
36
+ }
37
+ if (sender.url && !sender.url.startsWith(chrome.runtime.getURL(""))) throw new Error("Only the extension panel can manage feedback")
38
+ if (message.action === "current") return current()
39
+ if (message.action === "targets") return chrome.runtime.sendNativeMessage(HOST, { action: "targets" })
40
+ if (message.action === "connection") {
41
+ const result = await chrome.runtime.sendNativeMessage(HOST, { action: "targets" })
42
+ if (!result.ok) throw new Error(result.error)
43
+ const { paired } = await chrome.storage.local.get("paired")
44
+ const target = paired ? result.targets.find(target => target.id === paired.id) : result.targets.length === 1 ? result.targets[0] : null
45
+ return { targets: result.targets, target: target || null, stale: !!paired && !target }
46
+ }
47
+ if (message.action === "pair") {
48
+ const result = await chrome.runtime.sendNativeMessage(HOST, { action: "info", targetID: message.target.id })
49
+ if (!result.ok) throw new Error(result.error)
50
+ await chrome.storage.local.set({ paired: result.target }); return { target: result.target }
51
+ }
52
+ if (message.action === "pairing") return { target: (await chrome.storage.local.get("paired")).paired || null }
53
+ if (message.action === "unpair") { await chrome.storage.local.remove("paired"); return {} }
54
+ const state = await current()
55
+ if (message.url && message.url !== state.page.url) throw new Error("The active page changed; retry on the original page")
56
+ if (message.action === "pick") return chrome.tabs.sendMessage(state.tabID, { action: "pick" })
57
+ if (message.action === "save" || message.action === "delete") return serial(async () => {
58
+ const key = `note:${state.hash}:${message.note.id}`
59
+ const old = (await chrome.storage.local.get(key))[key]
60
+ if ((old?.revision || 0) !== message.baseRevision) throw new Error("This note changed in another tab; refresh and retry")
61
+ const note = { ...message.note, page: state.page, revision: (old?.revision || 0) + 1, deleted: message.action === "delete" }
62
+ validateReview({ version: 1, page: state.page, annotations: [note] })
63
+ if (!old && state.notes.length >= 200) throw new Error("This page has 200 notes; export and remove some first")
64
+ await chrome.storage.local.set({ [key]: note }); return { note }
65
+ })
66
+ if (message.action === "markers") return chrome.tabs.sendMessage(state.tabID, { action: "markers", notes: state.notes.map(note => ({ anchor: note.anchor })) })
67
+ if (message.action === "send") return serial(async () => {
68
+ const latest = await current()
69
+ if (latest.page.url !== state.page.url) throw new Error("The active page changed; return to the original page to send")
70
+ if (typeof message.targetID !== "string" || !message.targetID) throw new Error("Choose a destination session before sending")
71
+ let { paired } = await chrome.storage.local.get("paired")
72
+ if (paired && paired.id !== message.targetID) throw new Error("The destination changed in another panel; choose it again")
73
+ if (!paired) {
74
+ // A sole target is only preselected. The user's Send click authorizes this binding.
75
+ const result = await chrome.runtime.sendNativeMessage(HOST, { action: "info", targetID: message.targetID })
76
+ if (!result.ok) throw new Error(result.error)
77
+ paired = result.target
78
+ await chrome.storage.local.set({ paired })
79
+ }
80
+ const outboxKey = `outbox:${state.hash}`
81
+ let batch = (await chrome.storage.local.get(outboxKey))[outboxKey]
82
+ if (batch && batch.targetID !== paired.id) throw new Error("An unsent batch belongs to the previous pairing; restore that pairing or export your notes")
83
+ if (!batch) {
84
+ const pending = latest.notes.filter(note => note.sentRevision !== note.revision)
85
+ if (!pending.length) throw new Error("No new feedback to send")
86
+ const review = validateReview({ version: 1, page: state.page, annotations: pending })
87
+ batch = { action: "send", batchID: crypto.randomUUID(), targetID: paired.id, review }
88
+ await chrome.storage.local.set({ [outboxKey]: batch })
89
+ }
90
+ const response = await chrome.runtime.sendNativeMessage(HOST, batch)
91
+ if (!response.ok) throw new Error(response.error)
92
+ for (const sent of batch.review.annotations) {
93
+ const key = `note:${state.hash}:${sent.id}`
94
+ const note = (await chrome.storage.local.get(key))[key]
95
+ if (note && note.revision === sent.revision) await chrome.storage.local.set({ [key]: { ...note, sentRevision: sent.revision } })
96
+ }
97
+ await chrome.storage.local.remove(outboxKey)
98
+ return response
99
+ })
100
+ throw new Error("Unknown annotation action")
101
+ }
102
+ chrome.runtime.onMessage.addListener((message, sender, respond) => {
103
+ handle(message, sender).then(result => respond({ ok: true, ...result }), error => respond({ ok: false, error: error.message }))
104
+ return true
105
+ })
package/package.json CHANGED
@@ -1,28 +1,66 @@
1
1
  {
2
2
  "name": "opencode-plannotator-tui",
3
- "version": "0.2.2",
4
- "description": "Unofficial OpenCode adapter for Plannotator TUI: annotate replies and documents without leaving your terminal.",
3
+ "version": "0.4.2",
4
+ "description": "Unofficial OpenCode plugin: annotate replies, documents and webpages, then send the feedback to the originating session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./tui": "./tui.ts"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test review.test.mjs",
11
+ "test": "node --test review.test.mjs bridge/*.test.mjs",
12
+ "test:browser": "node tests/browser.mjs",
13
+ "install:bridge": "node scripts/install-bridge.mjs",
14
+ "install:plugin": "node scripts/install-plugin.mjs",
12
15
  "test:smoke": "python3 smoke.py",
13
16
  "check": "node --check review.mjs && npm test && npm pack --dry-run",
14
17
  "prepublishOnly": "npm run check"
15
18
  },
16
- "files": ["tui.ts", "review.mjs", "README.md", "LICENSE", "CHANGELOG.md"],
17
- "engines": { "node": ">=22" },
18
- "peerDependencies": { "@opencode/plugin": ">=2.0.22 <3" },
19
- "peerDependenciesMeta": { "@opencode/plugin": { "optional": true } },
19
+ "files": [
20
+ "tui.ts",
21
+ "review.mjs",
22
+ "targets.mjs",
23
+ "web-review.mjs",
24
+ "browser",
25
+ "bridge/native-host.mjs",
26
+ "bridge/receiver.mjs",
27
+ "bridge/wire.mjs",
28
+ "scripts/install-bridge.mjs",
29
+ "scripts/install-plugin.mjs",
30
+ "scripts/migrate-page-context.mjs",
31
+ "README.md",
32
+ "LICENSE",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "peerDependencies": {
39
+ "@opencode/plugin": ">=2.0.22 <3"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@opencode/plugin": {
43
+ "optional": true
44
+ }
45
+ },
20
46
  "repository": {
21
47
  "type": "git",
22
48
  "url": "git+https://github.com/nxxxsooo/opencode-plannotator-tui.git"
23
49
  },
24
50
  "homepage": "https://github.com/nxxxsooo/opencode-plannotator-tui#readme",
25
- "bugs": { "url": "https://github.com/nxxxsooo/opencode-plannotator-tui/issues" },
26
- "keywords": ["opencode", "opencode-plugin", "plannotator", "tui", "annotation", "markdown", "review"],
27
- "publishConfig": { "access": "public" }
51
+ "bugs": {
52
+ "url": "https://github.com/nxxxsooo/opencode-plannotator-tui/issues"
53
+ },
54
+ "keywords": [
55
+ "opencode",
56
+ "opencode-plugin",
57
+ "plannotator",
58
+ "tui",
59
+ "annotation",
60
+ "markdown",
61
+ "review"
62
+ ],
63
+ "publishConfig": {
64
+ "access": "public"
65
+ }
28
66
  }
package/review.mjs CHANGED
@@ -4,6 +4,8 @@ import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/
4
4
  import os from "node:os"
5
5
  import path from "node:path"
6
6
  import { promisify } from "node:util"
7
+ import { createWebReview } from "./web-review.mjs"
8
+ import { wantsBrowser } from "./targets.mjs"
7
9
 
8
10
  const execute = promisify(execFile)
9
11
  const documentExtensions = new Set([".md", ".mdx", ".markdown", ".txt"])
@@ -48,6 +50,11 @@ async function documentSource(input, client, sessionID, signal) {
48
50
  }
49
51
 
50
52
  export function selectReply(messages) {
53
+ return eligibleReplies(messages)[0]
54
+ }
55
+
56
+ function eligibleReplies(messages) {
57
+ const replies = []
51
58
  const newest = [...messages].sort((a, b) =>
52
59
  b.time.created - a.time.created || b.id.localeCompare(a.id),
53
60
  )
@@ -55,12 +62,14 @@ export function selectReply(messages) {
55
62
  if (message.type !== "assistant" || message.time.completed === undefined || message.error) continue
56
63
  if (["tool-calls", "error", "content-filter"].includes(message.finish)) continue
57
64
  const text = message.content.filter(part => part.type === "text").map(part => part.text).join("\n\n")
58
- if (text.trim()) return { id: message.id, text }
65
+ if (text.trim()) replies.push({ id: message.id, text })
59
66
  }
67
+ return replies
60
68
  }
61
69
 
62
- async function latestReply(client, sessionID, signal) {
70
+ async function latestReply(client, sessionID, signal, position = 1) {
63
71
  const seen = new Set()
72
+ const messages = new Set()
64
73
  let cursor
65
74
  do {
66
75
  signal.throwIfAborted()
@@ -68,8 +77,11 @@ async function latestReply(client, sessionID, signal) {
68
77
  sessionID, type: "assistant", limit: 50,
69
78
  ...(cursor ? { cursor } : { order: "desc" }),
70
79
  }, { signal })
71
- const reply = selectReply(page.data)
72
- if (reply) return reply
80
+ for (const reply of eligibleReplies(page.data)) {
81
+ if (messages.has(reply.id)) continue
82
+ messages.add(reply.id)
83
+ if (--position === 0) return reply
84
+ }
73
85
  cursor = page.cursor?.next
74
86
  if (cursor && seen.has(cursor)) throw new Error("Session history returned a repeated page cursor.")
75
87
  seen.add(cursor)
@@ -101,6 +113,7 @@ async function interactive({ binary, file, cwd, env, signal }) {
101
113
  }
102
114
 
103
115
  export function createReviewCommand(context, options = {}) {
116
+ const web = createWebReview(context, options.web)
104
117
  const binary = options.binary ?? context.options.binary ?? "plannotator-tui"
105
118
  const runInteractive = options.interactive ?? interactive
106
119
  let active
@@ -125,11 +138,22 @@ export function createReviewCommand(context, options = {}) {
125
138
  // The UI cache contains only a recent page; a long tool run can push the last
126
139
  // completed reply out of it. Read the session-scoped history, newest first.
127
140
  const target = input.trim()
128
- const source = target
141
+ if (wantsBrowser(target)) {
142
+ await web.run(target, sessionID)
143
+ return
144
+ }
145
+ const numeric = target !== "" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(target)
146
+ const position = numeric ? Number(target) : 1
147
+ if (numeric && (!/^\d+$/.test(target) || !Number.isSafeInteger(position) || position < 1)) {
148
+ throw new Error("Choose a positive integer reply number (1 = latest, 2 = previous).")
149
+ }
150
+ const source = target && !numeric
129
151
  ? await documentSource(target, context.client, sessionID, controller.signal)
130
- : await latestReply(context.client, sessionID, controller.signal)
152
+ : await latestReply(context.client, sessionID, controller.signal, position)
131
153
  if (controller.signal.aborted) return
132
- if (!source) return notice("This session has no completed assistant reply yet.")
154
+ if (!source) return notice(position === 1
155
+ ? "This session has no completed assistant reply yet."
156
+ : `Can't find reply ${position}: this session has fewer completed assistant text replies.`)
133
157
 
134
158
  directory = await mkdtemp(path.join(options.tempRoot ?? os.tmpdir(), "opencode-annotate-"))
135
159
  keepDraft = true
@@ -156,13 +180,12 @@ export function createReviewCommand(context, options = {}) {
156
180
  PLANNOTATOR_TUI_THEME: context.themeMode === "light" ? "light" : "dark",
157
181
  })
158
182
  context.ui.dialog.clear()
159
- context.renderer.suspend()
183
+ // OpenCode 2.1.1+ suspends and resumes asynchronously; resume forces a full repaint.
184
+ await context.renderer.suspend()
160
185
  try {
161
- context.renderer.currentRenderBuffer.clear()
162
186
  await runInteractive({ binary, file, cwd: directory, env, signal: controller.signal })
163
187
  } finally {
164
- context.renderer.currentRenderBuffer.clear()
165
- context.renderer.resume()
188
+ await context.renderer.resume()
166
189
  context.renderer.requestRender()
167
190
  }
168
191
  if (controller.signal.aborted) return
@@ -223,6 +246,7 @@ export function createReviewCommand(context, options = {}) {
223
246
  dispose() {
224
247
  disposed = true
225
248
  active?.abort()
249
+ return web.dispose()
226
250
  },
227
251
  }
228
252
  }
@@ -0,0 +1,27 @@
1
+ import { readFile, mkdir, writeFile, chmod, copyFile } from "node:fs/promises"
2
+ import { createHash } from "node:crypto"
3
+ import { fileURLToPath } from "node:url"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { registryRoot } from "../bridge/receiver.mjs"
7
+ const root = fileURLToPath(new URL("../", import.meta.url))
8
+ export async function installBridge({ profileDirectory = path.join(os.homedir(), "Library/Application Support/Google/Chrome"), installRoot = path.join(os.homedir(), ".local/share/opencode-annotate/native"), node = process.execPath, targetsDirectory = registryRoot() } = {}) {
9
+ const manifest = JSON.parse(await readFile(path.join(root, "browser/manifest.json"), "utf8"))
10
+ const extensionID = [...createHash("sha256").update(Buffer.from(manifest.key, "base64")).digest().subarray(0, 16)].flatMap(byte => [String.fromCharCode(97 + (byte >> 4)), String.fromCharCode(97 + (byte & 15))]).join("")
11
+ await mkdir(installRoot, { recursive: true, mode: 0o700 })
12
+ const launcher = path.join(installRoot, "host"), config = path.join(installRoot, "config.json")
13
+ const quote = value => `'${value.replaceAll("'", "'\\''")}'`
14
+ await writeFile(config, JSON.stringify({ extensionID, targetsDirectory }), { mode: 0o600 })
15
+ await writeFile(launcher, `#!/bin/sh\nexec ${quote(node)} ${quote(path.join(root, "bridge/native-host.mjs"))} ${quote(config)} "$@"\n`, { mode: 0o700 })
16
+ await chmod(launcher, 0o700)
17
+ const directory = path.join(profileDirectory, "NativeMessagingHosts")
18
+ await mkdir(directory, { recursive: true })
19
+ const hostManifest = path.join(directory, "fun.mjshao.annotate.json")
20
+ try { await copyFile(hostManifest, `${hostManifest}.backup-${Date.now()}`) } catch (error) { if (error.code !== "ENOENT") throw error }
21
+ await writeFile(hostManifest, JSON.stringify({ name: "fun.mjshao.annotate", description: "Paired OpenCode annotation delivery", path: launcher, type: "stdio", allowed_origins: [`chrome-extension://${extensionID}/`] }, null, 2), { mode: 0o600 })
22
+ return { extensionID, extensionDirectory: path.join(root, "browser"), hostManifest, launcher }
23
+ }
24
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
25
+ if (process.platform !== "darwin") throw new Error("Local installer currently supports macOS only")
26
+ console.log(JSON.stringify(await installBridge(), null, 2))
27
+ }
@@ -0,0 +1,34 @@
1
+ import { readFile, writeFile, mkdir, realpath, rename, stat } from "node:fs/promises"
2
+ import { fileURLToPath } from "node:url"
3
+ import { createHash } from "node:crypto"
4
+ import path from "node:path"
5
+ import os from "node:os"
6
+
7
+ const root = fileURLToPath(new URL("../", import.meta.url)).replace(/\/$/, "")
8
+ const source = entry => typeof entry === "string" ? entry : entry.package
9
+ const oldEntry = entry => source(entry) === "opencode-plannotator-tui" || source(entry)?.startsWith("opencode-plannotator-tui@")
10
+ const configPath = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode/cli.json")
11
+ const filename = await realpath(configPath)
12
+ const bytes = await readFile(filename, "utf8"), config = JSON.parse(bytes)
13
+ let backup
14
+ if (process.argv[2] === "--undo") {
15
+ if (!process.argv[3]) throw new Error("Supply the cli-before.json backup")
16
+ const previous = JSON.parse(await readFile(process.argv[3], "utf8"))
17
+ const old = previous.plugins.filter(oldEntry)
18
+ if (old.length !== 1 || config.plugins.filter(entry => source(entry) === root).length !== 1) throw new Error("Ambiguous plugin rollback; no changes made")
19
+ config.plugins = config.plugins.map(entry => source(entry) === root ? old[0] : entry)
20
+ } else {
21
+ const candidates = config.plugins.filter(oldEntry)
22
+ if (config.plugins.some(entry => source(entry) === root)) { console.log("Local plugin is already configured."); process.exit(0) }
23
+ if (candidates.length !== 1) throw new Error("Expected exactly one existing Plannotator TUI entry; no changes made")
24
+ const backupDir = path.join(root, ".local/migration")
25
+ await mkdir(backupDir, { recursive: true, mode: 0o700 })
26
+ backup = path.join(backupDir, `cli-before-${Date.now()}.json`)
27
+ await writeFile(backup, bytes, { mode: 0o600, flag: "wx" })
28
+ config.plugins = config.plugins.map(entry => oldEntry(entry) ? typeof entry === "string" ? root : { ...entry, package: root } : entry)
29
+ }
30
+ if (await readFile(filename, "utf8") !== bytes) throw new Error("CLI configuration changed concurrently; retry")
31
+ const output = `${JSON.stringify(config, null, 2)}\n`, temporary = `${filename}.annotate-${process.pid}`
32
+ await writeFile(temporary, output, { mode: (await stat(filename)).mode & 0o777, flag: "wx" })
33
+ await rename(temporary, filename)
34
+ console.log(JSON.stringify({ configured: root, config: filename, backup, installedSha256: createHash("sha256").update(output).digest("hex"), next: "Reopen the OpenCode terminal client. Do not restart the shared service." }, null, 2))
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+ import { fileURLToPath } from "node:url"
4
+ import { safeUrl, validateReview } from "../browser/protocol.mjs"
5
+
6
+ export function migratePageContext(storage) {
7
+ const result = {}
8
+ for (const [key, page] of Object.entries(storage)) {
9
+ if (!key.startsWith("page:")) continue
10
+ if (!page?.page?.url || !Array.isArray(page.notes)) throw new Error(`Unrecognized old draft: ${key}`)
11
+ const source = { url: safeUrl(page.page.url), title: page.page.title || "Page" }
12
+ const hash = createHash("sha256").update(source.url).digest("hex")
13
+ for (const old of page.notes) {
14
+ const note = { id: old.id, revision: 1, kind: old.kind, comment: old.body,
15
+ createdAt: old.createdAt, updatedAt: old.updatedAt || old.createdAt,
16
+ anchor: { ...old.target, element: old.target.label || old.target.role || old.target.type,
17
+ styles: old.target.styles || "", accessibility: old.target.accessibility || "", fullPath: old.target.selector }, page: source }
18
+ validateReview({ version: 1, page: source, annotations: [note] })
19
+ const destination = `note:${hash}:${note.id}`
20
+ if (result[destination]) throw new Error("Duplicate note during migration")
21
+ result[destination] = note
22
+ }
23
+ }
24
+ return result
25
+ }
26
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
27
+ if (!process.argv[2] || !process.argv[3]) throw new Error("Usage: migrate-page-context.mjs input.json output.json")
28
+ const records = migratePageContext(JSON.parse(await readFile(process.argv[2], "utf8")))
29
+ await writeFile(process.argv[3], JSON.stringify(records, null, 2), { mode: 0o600, flag: "wx" })
30
+ console.log(`Prepared ${Object.keys(records).length} notes for import; source backup unchanged.`)
31
+ }
package/targets.mjs ADDED
@@ -0,0 +1,32 @@
1
+ import { stat } from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
+
6
+ const unquote = input => {
7
+ const value = input.trim()
8
+ return ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1) : value
9
+ }
10
+ export const wantsBrowser = input => /^web(?: off)?$/.test(unquote(input)) || /\.html?$/i.test(unquote(input)) || /^[a-z][a-z0-9+.-]*:/i.test(unquote(input))
11
+
12
+ export async function browserTarget(input, directory) {
13
+ let value = unquote(input)
14
+ if (value === "web") return { type: "browser" }
15
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
16
+ const url = new URL(value)
17
+ if (["http:", "https:"].includes(url.protocol)) {
18
+ if (url.username || url.password) throw new Error("Remove credentials from the page URL.")
19
+ if (/^chrome(webstore)?\.google\.com$/.test(url.hostname) || /\.pdf$/i.test(url.pathname)) throw new Error("This page cannot be annotated.")
20
+ return { type: "browser", url: url.href }
21
+ }
22
+ if (url.protocol !== "file:") throw new Error("Use an http, https, or local HTML target.")
23
+ value = fileURLToPath(url)
24
+ if (!/\.html?$/i.test(value)) throw new Error("File URLs must point to an HTML document.")
25
+ }
26
+ if (!/\.html?$/i.test(value)) return null
27
+ if (value.startsWith("~/")) value = path.join(os.homedir(), value.slice(2))
28
+ if (!path.isAbsolute(value) && !directory) throw new Error("Can't determine the session working directory.")
29
+ const file = path.resolve(directory || ".", value)
30
+ if (!(await stat(file)).isFile()) throw new Error("The HTML target is not a file.")
31
+ return { type: "browser", url: pathToFileURL(file).href }
32
+ }
package/tui.ts CHANGED
@@ -2,7 +2,7 @@ import { Plugin } from "@opencode/plugin/tui"
2
2
  import { createReviewCommand } from "./review.mjs"
3
3
 
4
4
  export default Plugin.define({
5
- id: "opencode-plannotator-tui",
5
+ id: "opencode-annotate",
6
6
  setup(context) {
7
7
  const review = createReviewCommand(context)
8
8
  // 2.0.22's keymap.layer needs the mounted provider; newer hosts also allow setup().
@@ -12,13 +12,23 @@ export default Plugin.define({
12
12
  context.keymap.layer(() => ({
13
13
  mode: "global",
14
14
  commands: [{
15
- id: "opencode-plannotator-tui.annotate",
16
- title: "Annotate reply or document (TUI)",
17
- description: "Annotate the last reply or a file; press q, then confirm to send",
18
- group: "Plannotator",
15
+ id: "opencode-annotate.annotate",
16
+ title: "Annotate reply, document, or webpage",
17
+ description: "Text defaults to TUI; explicit HTML, URL or web pairs browser review",
18
+ group: "Annotate",
19
19
  palette: true,
20
- slash: { name: "annotate", aliases: ["annotate-last"], arguments: true },
20
+ slash: { name: "annotate", arguments: true },
21
21
  run: review.run,
22
+ }, {
23
+ id: "opencode-annotate.annotate-last",
24
+ title: "Annotate last reply (TUI)",
25
+ description: "Annotate the latest completed reply; press q, then confirm to send",
26
+ group: "Annotate",
27
+ palette: true,
28
+ // Keep slash submission with a trailing space local to the command.
29
+ // Any supplied argument is deliberately ignored: this always means latest.
30
+ slash: { name: "annotate-last", arguments: true },
31
+ run: () => review.run(),
22
32
  }],
23
33
  }))
24
34
  return null
package/web-review.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { execFile } from "node:child_process"
2
+ import { promisify } from "node:util"
3
+ import { browserTarget } from "./targets.mjs"
4
+ import { createReceiver } from "./bridge/receiver.mjs"
5
+
6
+ const execute = promisify(execFile)
7
+ export function createWebReview(context, options = {}) {
8
+ const receivers = new Map()
9
+ let disposed = false
10
+ return {
11
+ async run(input, sessionID) {
12
+ if (input.trim() === "web off") {
13
+ await receivers.get(sessionID)?.close(); receivers.delete(sessionID)
14
+ context.ui.toast.show({ title: "Annotate web", message: "Browser pairing revoked for this session." })
15
+ return true
16
+ }
17
+ const session = await context.client.session.get({ sessionID })
18
+ const target = await browserTarget(input, session.location?.directory)
19
+ if (!target) return false
20
+ if (disposed) throw new Error("Annotation plugin is closed")
21
+ if (!receivers.has(sessionID)) {
22
+ const receiver = await createReceiver({ sessionID, title: session.title || sessionID, directory: session.location?.directory || "", client: context.client, ...options.receiver })
23
+ if (disposed) { await receiver.close(); return true }
24
+ receivers.set(sessionID, receiver)
25
+ }
26
+ if (target.url) {
27
+ if (options.open) await options.open(target.url)
28
+ else if (process.platform === "darwin") await execute("open", ["-a", "Google Chrome", target.url])
29
+ else throw new Error("Browser review currently requires macOS")
30
+ }
31
+ context.ui.toast.show({ title: "Annotate web", message: `Ready for browser pairing: ${session.title || sessionID}. Open the extension and select this session.`, duration: 10000 })
32
+ return true
33
+ },
34
+ async dispose() { disposed = true; await Promise.all([...receivers.values()].map(receiver => receiver.close())); receivers.clear() },
35
+ }
36
+ }