opencode-plannotator-tui 0.2.0

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 ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ Initial public release of the OpenCode adapter for Plannotator TUI.
6
+
7
+ - `/annotate [file]` and `/annotate-last` for replies or local Markdown/text documents.
8
+ - Same-terminal handoff and confirmed feedback to the captured session.
9
+ - Paginated completed-reply lookup and isolated document snapshots.
10
+ - Original document path and snapshot hash included in feedback.
11
+ - Recovery drafts on failure and preservation of archived annotations.
12
+ - Fourteen behavior tests and real terminal/API smoke coverage.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nxxxsooo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # Plannotator TUI for OpenCode
2
+
3
+ [![npm](https://img.shields.io/npm/v/opencode-plannotator-tui)](https://www.npmjs.com/package/opencode-plannotator-tui)
4
+ [![CI](https://github.com/nxxxsooo/opencode-plannotator-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/nxxxsooo/opencode-plannotator-tui/actions/workflows/ci.yml)
5
+ [![MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+
7
+ **Annotate an AI reply or a document in your terminal, then send the feedback straight back to OpenCode.**
8
+
9
+ This is an **unofficial OpenCode adapter** for [Plannotator TUI](https://github.com/plannotator/plannotator-tui).
10
+ Plannotator TUI provides the annotation interface. This plugin supplies the commands,
11
+ session targeting, terminal handoff and confirmed feedback delivery. It is not an
12
+ official OpenCode or Plannotator release.
13
+
14
+ ```text
15
+ /annotate Review the last completed assistant reply
16
+ /annotate docs/plan.md Review a Markdown document
17
+ /annotate "docs/release plan.md" Review a path containing spaces
18
+ /annotate ~/Documents/notes.txt Review a plain-text document
19
+ ```
20
+
21
+ `/annotate-last` is an alias with the same optional file argument.
22
+
23
+ ## Install
24
+
25
+ Requires **OpenCode 2.0.22+** and a separately installed `plannotator-tui` on your PATH.
26
+ Tested with OpenCode 2.0.24 and Plannotator TUI 0.9.4 on macOS; unit tests also run on
27
+ Linux. Windows terminal handoff has not been verified.
28
+
29
+ Install the upstream TUI on macOS or Linux:
30
+
31
+ ```sh
32
+ brew install plannotator/tap/plannotator-tui
33
+ ```
34
+
35
+ Homebrew 6 may first require `brew trust plannotator/tap`. Alternatively, use
36
+ `cargo install plannotator-tui --locked` or the upstream
37
+ [prebuilt binaries](https://github.com/plannotator/plannotator-tui/releases).
38
+
39
+ Then install the adapter:
40
+
41
+ ```sh
42
+ opencode plugin add opencode-plannotator-tui@latest
43
+ ```
44
+
45
+ OpenCode detects its TUI-only entrypoint and adds it to your global **`cli.json`**.
46
+ It does not need a server-plugin entry. Reopen the terminal client if its command
47
+ list has not refreshed.
48
+
49
+ Manual configuration, preserving other entries:
50
+
51
+ ```json
52
+ {
53
+ "plugins": ["opencode-plannotator-tui@latest"]
54
+ }
55
+ ```
56
+
57
+ ## Review and send
58
+
59
+ 1. Run `/annotate` or `/annotate path/to/file.md` in a session.
60
+ 2. Select text by dragging, or press `v` and use movement keys.
61
+ 3. Press `c` to comment, `a` for “looks good,” or `d` to request deletion.
62
+ Without a selection, `c` comments on the current block. Enter saves a comment.
63
+ 4. Press **`q`** to return to OpenCode.
64
+ 5. Choose **发送并继续** (“Send and continue”) to submit the annotations. Escape or
65
+ **取消** cancels. The adapter's confirmation and status messages are currently Chinese.
66
+
67
+ The upstream **`E`** action copies to the clipboard. This adapter's automatic
68
+ delivery path is **q → confirm** and does not need clipboard access.
69
+
70
+ Opening a review makes no model call. Confirming sends a normal user prompt and
71
+ resumes the **original session**, even if the active tab changes. Empty and
72
+ cancelled reviews submit nothing.
73
+
74
+ ## What it reviews
75
+
76
+ **Replies:** the latest completed text reply, with paginated history lookup.
77
+ Reasoning, in-progress messages and tool-call commentary are excluded.
78
+
79
+ **Documents:** local UTF-8 `.md`, `.mdx`, `.markdown` and `.txt` files up to 2 MiB.
80
+ Relative paths resolve against the captured session's working directory, not the
81
+ directory from which the terminal client happened to start. Absolute and `~/`
82
+ paths work too. The file must exist on the machine running the terminal client.
83
+
84
+ The TUI reads a fixed snapshot and does not edit the original document. Feedback
85
+ includes the original path and snapshot hash, with instructions to re-read the
86
+ current file before applying suggestions. Document review works even before the
87
+ session has an assistant reply.
88
+
89
+ ## Updates and uninstall
90
+
91
+ `@latest` selects the npm release channel. OpenCode caches installed packages;
92
+ it does not follow this repository's commits. Update just this plugin with:
93
+
94
+ ```sh
95
+ opencode plugin update opencode-plannotator-tui@latest
96
+ ```
97
+
98
+ Reopen your terminal client to load updated package code. No shared background
99
+ service restart is required. Check the installed version with `opencode plugin list`.
100
+
101
+ To remove it:
102
+
103
+ ```sh
104
+ opencode plugin remove opencode-plannotator-tui@latest
105
+ ```
106
+
107
+ ## Configuration
108
+
109
+ Set `binary` if Plannotator TUI is installed outside the terminal client's PATH:
110
+
111
+ ```json
112
+ {
113
+ "plugins": [
114
+ {
115
+ "package": "opencode-plannotator-tui@latest",
116
+ "options": { "binary": "/path/to/plannotator-tui" }
117
+ }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ ## Draft recovery
123
+
124
+ Each review has its own private temporary source and annotation store. Successful,
125
+ empty and cancelled reviews are cleaned up. Failures and archived notes retain the
126
+ store, and a status message gives its directory.
127
+
128
+ `review.json` records the session, original source and relative `snapshot` path.
129
+ `feedback.md` holds exported feedback when available. To recover a retained review:
130
+
131
+ ```sh
132
+ PLANNOTATOR_DATA_DIR="<retained-directory>/data" \
133
+ plannotator-tui "<retained-directory>/<snapshot>"
134
+ ```
135
+
136
+ Use the `snapshot` value from `review.json`. Press `H` to restore archived notes,
137
+ then `E` to copy them for pasting into OpenCode. An upstream **E → F** archive is
138
+ preserved even when `--export` reports “No annotations.”
139
+
140
+ ## Development
141
+
142
+ ```sh
143
+ npm ci
144
+ npm run check
145
+ npm run test:smoke
146
+ ```
147
+
148
+ Unit tests use the real upstream annotation/export binary. The optional smoke test
149
+ also requires Python 3, tmux and OpenCode. It runs a detached terminal, verifies
150
+ commands and feedback through the actual API, then removes its test session.
151
+ Its submission uses `resume: false`, so it makes no model call or visible window.
152
+
153
+ For development loading, add this checkout's absolute directory to `cli.json`
154
+ instead of the npm package. Use only one installation at a time.
155
+
156
+ See [release instructions](docs/releasing.md) and [changelog](CHANGELOG.md).
157
+
158
+ ## 中文速用
159
+
160
+ 这是 **Plannotator TUI 的非官方 OpenCode 适配插件**。
161
+
162
+ - `/annotate`:批注上一条完整回复。
163
+ - `/annotate 文档.md`:批注文档,支持中文、空格和相对路径。
164
+ - `/annotate-last`:同义入口,也可带文件路径。
165
+ - 选中文字 → `c` 写批注 → Enter 保存 → `q` 返回 →「发送并继续」。
166
+ - 文档采用独立快照,反馈附原文件路径,确认后发回发起批注的会话。
167
+ - 更新:`opencode plugin update opencode-plannotator-tui@latest`,然后重新打开终端客户端。
168
+
169
+ MIT licensed. The annotation UI is maintained upstream by the Plannotator project.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "opencode-plannotator-tui",
3
+ "version": "0.2.0",
4
+ "description": "Unofficial OpenCode adapter for Plannotator TUI: annotate replies and documents without leaving your terminal.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ "./tui": "./tui.ts"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test review.test.mjs",
12
+ "test:smoke": "python3 smoke.py",
13
+ "check": "node --check review.mjs && npm test && npm pack --dry-run",
14
+ "prepublishOnly": "npm run check"
15
+ },
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 } },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/nxxxsooo/opencode-plannotator-tui.git"
23
+ },
24
+ "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" }
28
+ }
package/review.mjs ADDED
@@ -0,0 +1,228 @@
1
+ import { spawn, execFile } from "node:child_process"
2
+ import { createHash, randomUUID } from "node:crypto"
3
+ import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { promisify } from "node:util"
7
+
8
+ const execute = promisify(execFile)
9
+ const documentExtensions = new Set([".md", ".mdx", ".markdown", ".txt"])
10
+ const maxDocumentBytes = 2 * 1024 * 1024
11
+
12
+ async function documentSource(input, client, sessionID, signal) {
13
+ let target = input.trim()
14
+ if ((target.startsWith('"') && target.endsWith('"')) || (target.startsWith("'") && target.endsWith("'"))) {
15
+ target = target.slice(1, -1)
16
+ }
17
+ if (!target || !documentExtensions.has(path.extname(target).toLowerCase())) {
18
+ throw new Error("请指定一个 Markdown 或纯文本文件(.md、.mdx、.markdown、.txt)。")
19
+ }
20
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) throw new Error("请指定本地文件路径。")
21
+ if (target.startsWith("~/")) target = path.join(os.homedir(), target.slice(2))
22
+ const session = await client.session.get({ sessionID }, { signal })
23
+ const directory = session.location?.directory
24
+ if (!path.isAbsolute(target) && !directory) throw new Error("无法确定当前会话的工作目录。")
25
+ const filePath = path.isAbsolute(target) ? path.normalize(target) : path.resolve(directory, target)
26
+ try {
27
+ const info = await stat(filePath)
28
+ if (!info.isFile()) throw new Error("目标不是文件。")
29
+ if (info.size > maxDocumentBytes) throw new Error("文件超过 2 MiB,请先选取较小的文档。")
30
+ const bytes = await readFile(filePath, { signal })
31
+ if (bytes.length > maxDocumentBytes) throw new Error("文件超过 2 MiB。")
32
+ if (bytes.includes(0)) throw new Error("文件包含二进制内容。")
33
+ let text
34
+ try {
35
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
36
+ } catch {
37
+ throw new Error("请使用 UTF-8 编码的文本文件。")
38
+ }
39
+ return {
40
+ text, filePath,
41
+ sourceHash: createHash("sha256").update(bytes).digest("hex"),
42
+ snapshot: path.join("source", path.basename(filePath)),
43
+ }
44
+ } catch (error) {
45
+ if (signal.aborted) throw error
46
+ throw new Error(`无法读取文档「${filePath}」:${error.code === "ENOENT" ? "文件不存在。" : error.message}`)
47
+ }
48
+ }
49
+
50
+ export function selectReply(messages) {
51
+ const newest = [...messages].sort((a, b) =>
52
+ b.time.created - a.time.created || b.id.localeCompare(a.id),
53
+ )
54
+ for (const message of newest) {
55
+ if (message.type !== "assistant" || message.time.completed === undefined || message.error) continue
56
+ if (["tool-calls", "error", "content-filter"].includes(message.finish)) continue
57
+ 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 }
59
+ }
60
+ }
61
+
62
+ async function latestReply(client, sessionID, signal) {
63
+ const seen = new Set()
64
+ let cursor
65
+ do {
66
+ signal.throwIfAborted()
67
+ const page = await client.message.list({
68
+ sessionID, type: "assistant", limit: 50,
69
+ ...(cursor ? { cursor } : { order: "desc" }),
70
+ }, { signal })
71
+ const reply = selectReply(page.data)
72
+ if (reply) return reply
73
+ cursor = page.cursor?.next
74
+ if (cursor && seen.has(cursor)) throw new Error("读取会话历史时收到重复分页游标。")
75
+ seen.add(cursor)
76
+ } while (cursor)
77
+ }
78
+
79
+ async function archivedNotes(data) {
80
+ let count = 0
81
+ for (const name of await readdir(data, { recursive: true })) {
82
+ if (path.basename(name) !== "annotations.json") continue
83
+ const record = JSON.parse(await readFile(path.join(data, name), "utf8"))
84
+ count += record.archived?.length ?? 0
85
+ }
86
+ return count
87
+ }
88
+
89
+ async function interactive({ binary, file, cwd, env, signal }) {
90
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
91
+ throw new Error("请在 OpenCode 完整终端界面中运行 /annotate。")
92
+ }
93
+ await new Promise((resolve, reject) => {
94
+ const child = spawn(binary, [file], { cwd, env, stdio: "inherit", signal })
95
+ child.once("error", reject)
96
+ child.once("close", (code, termination) => {
97
+ if (code === 0) resolve()
98
+ else reject(new Error(`plannotator-tui exited: ${termination ?? code}`))
99
+ })
100
+ })
101
+ }
102
+
103
+ export function createReviewCommand(context, options = {}) {
104
+ const binary = options.binary ?? context.options.binary ?? "plannotator-tui"
105
+ const runInteractive = options.interactive ?? interactive
106
+ let active
107
+ let disposed = false
108
+ const notice = (message, variant = "info") => context.ui.toast.show({
109
+ title: "Annotate", message, variant, duration: variant === "error" ? 15000 : 5000,
110
+ })
111
+
112
+ async function run(input = "") {
113
+ if (disposed) return
114
+ if (active) return notice("已有批注正在进行。")
115
+ // Capture before the first await: changing tabs must never redirect a review.
116
+ const route = context.ui.router.current()
117
+ if (route.type !== "session") return notice("请先打开一个会话。")
118
+ const sessionID = route.sessionID
119
+ const title = context.data.session.get(sessionID)?.title ?? sessionID
120
+ const controller = new AbortController()
121
+ active = controller
122
+ let directory
123
+ let keepDraft = false
124
+ try {
125
+ // The UI cache contains only a recent page; a long tool run can push the last
126
+ // completed reply out of it. Read the session-scoped history, newest first.
127
+ const target = input.trim()
128
+ const source = target
129
+ ? await documentSource(target, context.client, sessionID, controller.signal)
130
+ : await latestReply(context.client, sessionID, controller.signal)
131
+ if (controller.signal.aborted) return
132
+ if (!source) return notice("当前会话还没有已完成的助手回复。")
133
+
134
+ directory = await mkdtemp(path.join(options.tempRoot ?? os.tmpdir(), "opencode-annotate-"))
135
+ keepDraft = true
136
+ const snapshot = source.snapshot ?? "last-reply.md"
137
+ const file = path.join(directory, snapshot)
138
+ const data = path.join(directory, "data")
139
+ const promptID = `msg_${randomUUID().replaceAll("-", "")}`
140
+ await mkdir(data, { mode: 0o700 })
141
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
142
+ await writeFile(file, source.text, { mode: 0o600 })
143
+ await writeFile(path.join(directory, "review.json"), JSON.stringify({
144
+ sessionID, messageID: source.id, filePath: source.filePath,
145
+ sourceHash: source.sourceHash, snapshot, promptID, title,
146
+ }, null, 2), { mode: 0o600 })
147
+
148
+ // Use a fresh store per invocation, and disable any inherited pane delivery.
149
+ // The OpenCode confirmation is the sole authority for sending feedback.
150
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) =>
151
+ !key.startsWith("HERDR_") && !key.startsWith("PLANNOTATOR_TUI_"),
152
+ ))
153
+ Object.assign(env, {
154
+ PLANNOTATOR_DATA_DIR: data,
155
+ PLANNOTATOR_FEEDBACK_HISTORY: "0",
156
+ PLANNOTATOR_TUI_THEME: context.themeMode === "light" ? "light" : "dark",
157
+ })
158
+ context.ui.dialog.clear()
159
+ context.renderer.suspend()
160
+ try {
161
+ context.renderer.currentRenderBuffer.clear()
162
+ await runInteractive({ binary, file, cwd: directory, env, signal: controller.signal })
163
+ } finally {
164
+ context.renderer.currentRenderBuffer.clear()
165
+ context.renderer.resume()
166
+ context.renderer.requestRender()
167
+ }
168
+ if (controller.signal.aborted) return
169
+ const { stdout } = await execute(binary, ["--export", file], {
170
+ cwd: directory, env, signal: controller.signal, timeout: 15000, maxBuffer: 8 * 1024 * 1024,
171
+ })
172
+ const feedback = stdout.trim()
173
+ const archived = await archivedNotes(data)
174
+ const retainedArchive = archived ? `\n${archived} 条归档批注保留在 ${directory}。` : ""
175
+ // Upstream 0.9.4 emits this sentinel rather than empty stdout.
176
+ if (!feedback || feedback === "No annotations.") {
177
+ keepDraft = archived > 0
178
+ return notice(archived ? `没有待发送的批注。${retainedArchive}` : "没有批注,已返回会话。", archived ? "warning" : "info")
179
+ }
180
+ if (!feedback.startsWith("# Annotations on ")) throw new Error("无法识别 TUI 的批注导出格式。")
181
+ const subject = source.filePath
182
+ ? `以下是我对文档 ${JSON.stringify(source.filePath)} 的批注,请按反馈处理该文件。\n批注基于打开时的文档快照(SHA-256:${source.sourceHash});修改前请重新读取原文件并核对引用。`
183
+ : `以下是我对你回复 ${source.id} 的批注,请按反馈处理。`
184
+ const text = `${subject}\n\n${feedback}`
185
+ const feedbackPath = path.join(directory, "feedback.md")
186
+ await writeFile(feedbackPath, text, { mode: 0o600 })
187
+ const count = (feedback.match(/^## Annotation /gm) ?? []).length
188
+ const confirmed = await context.ui.dialog.confirm({
189
+ title: "发送批注?",
190
+ message: `${source.filePath ? `文档:${source.filePath}\n` : ""}将 ${count} 条批注发送到「${title}」,并让助手继续处理?`,
191
+ label: { confirm: "发送并继续", cancel: "取消" },
192
+ })
193
+ if (controller.signal.aborted) return
194
+ if (confirmed !== true) {
195
+ keepDraft = archived > 0
196
+ return notice(`已取消发送。${retainedArchive}`)
197
+ }
198
+ await context.client.session.prompt({
199
+ sessionID, id: promptID, text, delivery: "queue", resume: true,
200
+ })
201
+ keepDraft = archived > 0
202
+ notice(`已发送 ${count} 条批注。${retainedArchive}`, "success")
203
+ } catch (error) {
204
+ if (!disposed) {
205
+ const detail = error?.code === "ENOENT"
206
+ ? "找不到 plannotator-tui;请检查安装和 PATH。"
207
+ : error instanceof Error ? error.message : String(error)
208
+ const recovery = keepDraft && directory
209
+ ? `\n草稿保留在 ${directory};若已导出,反馈文件为 feedback.md。`
210
+ : ""
211
+ notice(`${detail}${recovery}`, "error")
212
+ }
213
+ } finally {
214
+ if (directory && !keepDraft) {
215
+ await rm(directory, { recursive: true, force: true }).catch(() => {})
216
+ }
217
+ active = undefined
218
+ }
219
+ }
220
+
221
+ return {
222
+ run,
223
+ dispose() {
224
+ disposed = true
225
+ active?.abort()
226
+ },
227
+ }
228
+ }
package/tui.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { createReviewCommand } from "./review.mjs"
3
+
4
+ export default Plugin.define({
5
+ id: "opencode-plannotator-tui",
6
+ setup(context) {
7
+ const review = createReviewCommand(context)
8
+ // 2.0.22's keymap.layer needs the mounted provider; newer hosts also allow setup().
9
+ const unmount = context.ui.slot({
10
+ append: "app",
11
+ render() {
12
+ context.keymap.layer(() => ({
13
+ mode: "global",
14
+ commands: [{
15
+ id: "opencode-plannotator-tui.annotate",
16
+ title: "Annotate reply or document (TUI)",
17
+ description: "批注回复或文档;可填写文件路径,按 q 返回后确认发送",
18
+ group: "Plannotator",
19
+ palette: true,
20
+ slash: { name: "annotate", aliases: ["annotate-last"], arguments: true },
21
+ run: review.run,
22
+ }],
23
+ }))
24
+ return null
25
+ },
26
+ })
27
+ return () => {
28
+ review.dispose()
29
+ unmount()
30
+ }
31
+ },
32
+ })