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.
- package/CHANGELOG.md +27 -0
- package/README.md +91 -13
- 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 +48 -10
- package/review.mjs +35 -11
- 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 +16 -6
- package/web-review.mjs +36 -0
|
@@ -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.
|
|
4
|
-
"description": "Unofficial OpenCode
|
|
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": [
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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": {
|
|
26
|
-
|
|
27
|
-
|
|
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())
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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-
|
|
16
|
-
title: "Annotate reply or
|
|
17
|
-
description: "
|
|
18
|
-
group: "
|
|
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",
|
|
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
|
+
}
|