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
|
@@ -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,14 +1,17 @@
|
|
|
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"
|
|
@@ -16,6 +19,15 @@
|
|
|
16
19
|
"files": [
|
|
17
20
|
"tui.ts",
|
|
18
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",
|
|
19
31
|
"README.md",
|
|
20
32
|
"LICENSE",
|
|
21
33
|
"CHANGELOG.md"
|
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"])
|
|
@@ -111,6 +113,7 @@ async function interactive({ binary, file, cwd, env, signal }) {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
export function createReviewCommand(context, options = {}) {
|
|
116
|
+
const web = createWebReview(context, options.web)
|
|
114
117
|
const binary = options.binary ?? context.options.binary ?? "plannotator-tui"
|
|
115
118
|
const runInteractive = options.interactive ?? interactive
|
|
116
119
|
let active
|
|
@@ -135,6 +138,10 @@ export function createReviewCommand(context, options = {}) {
|
|
|
135
138
|
// The UI cache contains only a recent page; a long tool run can push the last
|
|
136
139
|
// completed reply out of it. Read the session-scoped history, newest first.
|
|
137
140
|
const target = input.trim()
|
|
141
|
+
if (wantsBrowser(target)) {
|
|
142
|
+
await web.run(target, sessionID)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
138
145
|
const numeric = target !== "" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(target)
|
|
139
146
|
const position = numeric ? Number(target) : 1
|
|
140
147
|
if (numeric && (!/^\d+$/.test(target) || !Number.isSafeInteger(position) || position < 1)) {
|
|
@@ -173,13 +180,12 @@ export function createReviewCommand(context, options = {}) {
|
|
|
173
180
|
PLANNOTATOR_TUI_THEME: context.themeMode === "light" ? "light" : "dark",
|
|
174
181
|
})
|
|
175
182
|
context.ui.dialog.clear()
|
|
176
|
-
|
|
183
|
+
// OpenCode 2.1.1+ suspends and resumes asynchronously; resume forces a full repaint.
|
|
184
|
+
await context.renderer.suspend()
|
|
177
185
|
try {
|
|
178
|
-
context.renderer.currentRenderBuffer.clear()
|
|
179
186
|
await runInteractive({ binary, file, cwd: directory, env, signal: controller.signal })
|
|
180
187
|
} finally {
|
|
181
|
-
context.renderer.
|
|
182
|
-
context.renderer.resume()
|
|
188
|
+
await context.renderer.resume()
|
|
183
189
|
context.renderer.requestRender()
|
|
184
190
|
}
|
|
185
191
|
if (controller.signal.aborted) return
|
|
@@ -240,6 +246,7 @@ export function createReviewCommand(context, options = {}) {
|
|
|
240
246
|
dispose() {
|
|
241
247
|
disposed = true
|
|
242
248
|
active?.abort()
|
|
249
|
+
return web.dispose()
|
|
243
250
|
},
|
|
244
251
|
}
|
|
245
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,18 +12,18 @@ 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
20
|
slash: { name: "annotate", arguments: true },
|
|
21
21
|
run: review.run,
|
|
22
22
|
}, {
|
|
23
|
-
id: "opencode-
|
|
23
|
+
id: "opencode-annotate.annotate-last",
|
|
24
24
|
title: "Annotate last reply (TUI)",
|
|
25
25
|
description: "Annotate the latest completed reply; press q, then confirm to send",
|
|
26
|
-
group: "
|
|
26
|
+
group: "Annotate",
|
|
27
27
|
palette: true,
|
|
28
28
|
// Keep slash submission with a trailing space local to the command.
|
|
29
29
|
// Any supplied argument is deliberately ignored: this always means latest.
|
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
|
+
}
|