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 +12 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/package.json +28 -0
- package/review.mjs +228 -0
- package/tui.ts +32 -0
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
|
+
[](https://www.npmjs.com/package/opencode-plannotator-tui)
|
|
4
|
+
[](https://github.com/nxxxsooo/opencode-plannotator-tui/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
+
})
|