opencode-plannotator-tui 0.2.1 → 0.3.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 +16 -0
- package/README.md +11 -5
- package/package.json +34 -8
- package/review.mjs +49 -32
- package/tui.ts +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Select older completed replies with `/annotate 2`, `/annotate 3`, and so on.
|
|
6
|
+
Bare `/annotate` and `/annotate 1` still select the latest reply.
|
|
7
|
+
- Count only eligible completed text replies across history pages; report missing
|
|
8
|
+
replies and invalid numbers without opening a different source.
|
|
9
|
+
- `/annotate-last` now always selects the latest reply and ignores arguments.
|
|
10
|
+
**Migration:** replace `/annotate-last file.md` with `/annotate file.md`.
|
|
11
|
+
- Verify numbered feedback delivery and fixed latest-reply selection in the real
|
|
12
|
+
terminal smoke test, alongside 21 behavior tests.
|
|
13
|
+
|
|
14
|
+
## 0.2.2
|
|
15
|
+
|
|
16
|
+
- All confirmation dialogs, status notices, errors, and the feedback header sent
|
|
17
|
+
to the assistant are now in English.
|
|
18
|
+
|
|
3
19
|
## 0.2.1
|
|
4
20
|
|
|
5
21
|
- Use an English command description in the slash menu and command palette.
|
package/README.md
CHANGED
|
@@ -13,12 +13,14 @@ official OpenCode or Plannotator release.
|
|
|
13
13
|
|
|
14
14
|
```text
|
|
15
15
|
/annotate Review the last completed assistant reply
|
|
16
|
+
/annotate 2 Review the second most recent reply
|
|
17
|
+
/annotate 3 Review the third most recent reply
|
|
16
18
|
/annotate docs/plan.md Review a Markdown document
|
|
17
19
|
/annotate "docs/release plan.md" Review a path containing spaces
|
|
18
20
|
/annotate ~/Documents/notes.txt Review a plain-text document
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
`/annotate-last`
|
|
23
|
+
`/annotate-last` always reviews the latest completed reply; any supplied argument is ignored.
|
|
22
24
|
|
|
23
25
|
## Install
|
|
24
26
|
|
|
@@ -61,8 +63,8 @@ Manual configuration, preserving other entries:
|
|
|
61
63
|
3. Press `c` to comment, `a` for “looks good,” or `d` to request deletion.
|
|
62
64
|
Without a selection, `c` comments on the current block. Enter saves a comment.
|
|
63
65
|
4. Press **`q`** to return to OpenCode.
|
|
64
|
-
5. Choose
|
|
65
|
-
|
|
66
|
+
5. Choose **Send and continue** to submit the annotations. Escape or **Cancel**
|
|
67
|
+
cancels.
|
|
66
68
|
|
|
67
69
|
The upstream **`E`** action copies to the clipboard. This adapter's automatic
|
|
68
70
|
delivery path is **q → confirm** and does not need clipboard access.
|
|
@@ -75,6 +77,9 @@ cancelled reviews submit nothing.
|
|
|
75
77
|
|
|
76
78
|
**Replies:** the latest completed text reply, with paginated history lookup.
|
|
77
79
|
Reasoning, in-progress messages and tool-call commentary are excluded.
|
|
80
|
+
Pass a positive integer to select the Nth most recent eligible reply: `1` is the
|
|
81
|
+
default, `2` is the previous reply, and so on. If there are too few replies, the
|
|
82
|
+
plugin reports that instead of opening another reply.
|
|
78
83
|
|
|
79
84
|
**Documents:** local UTF-8 `.md`, `.mdx`, `.markdown` and `.txt` files up to 2 MiB.
|
|
80
85
|
Relative paths resolve against the captured session's working directory, not the
|
|
@@ -160,9 +165,10 @@ See [release instructions](docs/releasing.md) and [changelog](CHANGELOG.md).
|
|
|
160
165
|
这是 **Plannotator TUI 的非官方 OpenCode 适配插件**。
|
|
161
166
|
|
|
162
167
|
- `/annotate`:批注上一条完整回复。
|
|
168
|
+
- `/annotate 2`:批注倒数第二条,`3`、`4` 依次往前;`1` 等同于默认。
|
|
163
169
|
- `/annotate 文档.md`:批注文档,支持中文、空格和相对路径。
|
|
164
|
-
- `/annotate-last
|
|
165
|
-
- 选中文字 → `c` 写批注 → Enter 保存 → `q` 返回
|
|
170
|
+
- `/annotate-last`:固定批注最新一条完整回复,忽略附带参数。
|
|
171
|
+
- 选中文字 → `c` 写批注 → Enter 保存 → `q` 返回 →「Send and continue」。
|
|
166
172
|
- 文档采用独立快照,反馈附原文件路径,确认后发回发起批注的会话。
|
|
167
173
|
- 更新:`opencode plugin update opencode-plannotator-tui@latest`,然后重新打开终端客户端。
|
|
168
174
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plannotator-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Unofficial OpenCode adapter for Plannotator TUI: annotate replies and documents without leaving your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,16 +13,42 @@
|
|
|
13
13
|
"check": "node --check review.mjs && npm test && npm pack --dry-run",
|
|
14
14
|
"prepublishOnly": "npm run check"
|
|
15
15
|
},
|
|
16
|
-
"files": [
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
"files": [
|
|
17
|
+
"tui.ts",
|
|
18
|
+
"review.mjs",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"CHANGELOG.md"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@opencode/plugin": ">=2.0.22 <3"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@opencode/plugin": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
20
34
|
"repository": {
|
|
21
35
|
"type": "git",
|
|
22
36
|
"url": "git+https://github.com/nxxxsooo/opencode-plannotator-tui.git"
|
|
23
37
|
},
|
|
24
38
|
"homepage": "https://github.com/nxxxsooo/opencode-plannotator-tui#readme",
|
|
25
|
-
"bugs": {
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/nxxxsooo/opencode-plannotator-tui/issues"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"opencode",
|
|
44
|
+
"opencode-plugin",
|
|
45
|
+
"plannotator",
|
|
46
|
+
"tui",
|
|
47
|
+
"annotation",
|
|
48
|
+
"markdown",
|
|
49
|
+
"review"
|
|
50
|
+
],
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
28
54
|
}
|
package/review.mjs
CHANGED
|
@@ -15,26 +15,26 @@ async function documentSource(input, client, sessionID, signal) {
|
|
|
15
15
|
target = target.slice(1, -1)
|
|
16
16
|
}
|
|
17
17
|
if (!target || !documentExtensions.has(path.extname(target).toLowerCase())) {
|
|
18
|
-
throw new Error("
|
|
18
|
+
throw new Error("Choose a Markdown or plain-text file (.md, .mdx, .markdown, .txt).")
|
|
19
19
|
}
|
|
20
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) throw new Error("
|
|
20
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) throw new Error("Use a local file path.")
|
|
21
21
|
if (target.startsWith("~/")) target = path.join(os.homedir(), target.slice(2))
|
|
22
22
|
const session = await client.session.get({ sessionID }, { signal })
|
|
23
23
|
const directory = session.location?.directory
|
|
24
|
-
if (!path.isAbsolute(target) && !directory) throw new Error("
|
|
24
|
+
if (!path.isAbsolute(target) && !directory) throw new Error("Can't determine the current session's working directory.")
|
|
25
25
|
const filePath = path.isAbsolute(target) ? path.normalize(target) : path.resolve(directory, target)
|
|
26
26
|
try {
|
|
27
27
|
const info = await stat(filePath)
|
|
28
|
-
if (!info.isFile()) throw new Error("
|
|
29
|
-
if (info.size > maxDocumentBytes) throw new Error("
|
|
28
|
+
if (!info.isFile()) throw new Error("The target is not a file.")
|
|
29
|
+
if (info.size > maxDocumentBytes) throw new Error("The file is larger than 2 MiB. Choose a smaller document.")
|
|
30
30
|
const bytes = await readFile(filePath, { signal })
|
|
31
|
-
if (bytes.length > maxDocumentBytes) throw new Error("
|
|
32
|
-
if (bytes.includes(0)) throw new Error("
|
|
31
|
+
if (bytes.length > maxDocumentBytes) throw new Error("The file is larger than 2 MiB.")
|
|
32
|
+
if (bytes.includes(0)) throw new Error("The file contains binary content.")
|
|
33
33
|
let text
|
|
34
34
|
try {
|
|
35
35
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
36
36
|
} catch {
|
|
37
|
-
throw new Error("
|
|
37
|
+
throw new Error("Use a UTF-8 text file.")
|
|
38
38
|
}
|
|
39
39
|
return {
|
|
40
40
|
text, filePath,
|
|
@@ -43,11 +43,16 @@ async function documentSource(input, client, sessionID, signal) {
|
|
|
43
43
|
}
|
|
44
44
|
} catch (error) {
|
|
45
45
|
if (signal.aborted) throw error
|
|
46
|
-
throw new Error(
|
|
46
|
+
throw new Error(`Can't read document "${filePath}": ${error.code === "ENOENT" ? "file not found." : error.message}`)
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export function selectReply(messages) {
|
|
51
|
+
return eligibleReplies(messages)[0]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function eligibleReplies(messages) {
|
|
55
|
+
const replies = []
|
|
51
56
|
const newest = [...messages].sort((a, b) =>
|
|
52
57
|
b.time.created - a.time.created || b.id.localeCompare(a.id),
|
|
53
58
|
)
|
|
@@ -55,12 +60,14 @@ export function selectReply(messages) {
|
|
|
55
60
|
if (message.type !== "assistant" || message.time.completed === undefined || message.error) continue
|
|
56
61
|
if (["tool-calls", "error", "content-filter"].includes(message.finish)) continue
|
|
57
62
|
const text = message.content.filter(part => part.type === "text").map(part => part.text).join("\n\n")
|
|
58
|
-
if (text.trim())
|
|
63
|
+
if (text.trim()) replies.push({ id: message.id, text })
|
|
59
64
|
}
|
|
65
|
+
return replies
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
async function latestReply(client, sessionID, signal) {
|
|
68
|
+
async function latestReply(client, sessionID, signal, position = 1) {
|
|
63
69
|
const seen = new Set()
|
|
70
|
+
const messages = new Set()
|
|
64
71
|
let cursor
|
|
65
72
|
do {
|
|
66
73
|
signal.throwIfAborted()
|
|
@@ -68,10 +75,13 @@ async function latestReply(client, sessionID, signal) {
|
|
|
68
75
|
sessionID, type: "assistant", limit: 50,
|
|
69
76
|
...(cursor ? { cursor } : { order: "desc" }),
|
|
70
77
|
}, { signal })
|
|
71
|
-
const reply
|
|
72
|
-
|
|
78
|
+
for (const reply of eligibleReplies(page.data)) {
|
|
79
|
+
if (messages.has(reply.id)) continue
|
|
80
|
+
messages.add(reply.id)
|
|
81
|
+
if (--position === 0) return reply
|
|
82
|
+
}
|
|
73
83
|
cursor = page.cursor?.next
|
|
74
|
-
if (cursor && seen.has(cursor)) throw new Error("
|
|
84
|
+
if (cursor && seen.has(cursor)) throw new Error("Session history returned a repeated page cursor.")
|
|
75
85
|
seen.add(cursor)
|
|
76
86
|
} while (cursor)
|
|
77
87
|
}
|
|
@@ -88,7 +98,7 @@ async function archivedNotes(data) {
|
|
|
88
98
|
|
|
89
99
|
async function interactive({ binary, file, cwd, env, signal }) {
|
|
90
100
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
91
|
-
throw new Error("
|
|
101
|
+
throw new Error("Run /annotate in the full OpenCode terminal interface.")
|
|
92
102
|
}
|
|
93
103
|
await new Promise((resolve, reject) => {
|
|
94
104
|
const child = spawn(binary, [file], { cwd, env, stdio: "inherit", signal })
|
|
@@ -111,10 +121,10 @@ export function createReviewCommand(context, options = {}) {
|
|
|
111
121
|
|
|
112
122
|
async function run(input = "") {
|
|
113
123
|
if (disposed) return
|
|
114
|
-
if (active) return notice("
|
|
124
|
+
if (active) return notice("An annotation is already in progress.")
|
|
115
125
|
// Capture before the first await: changing tabs must never redirect a review.
|
|
116
126
|
const route = context.ui.router.current()
|
|
117
|
-
if (route.type !== "session") return notice("
|
|
127
|
+
if (route.type !== "session") return notice("Open a session first.")
|
|
118
128
|
const sessionID = route.sessionID
|
|
119
129
|
const title = context.data.session.get(sessionID)?.title ?? sessionID
|
|
120
130
|
const controller = new AbortController()
|
|
@@ -125,11 +135,18 @@ export function createReviewCommand(context, options = {}) {
|
|
|
125
135
|
// The UI cache contains only a recent page; a long tool run can push the last
|
|
126
136
|
// completed reply out of it. Read the session-scoped history, newest first.
|
|
127
137
|
const target = input.trim()
|
|
128
|
-
const
|
|
138
|
+
const numeric = target !== "" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(target)
|
|
139
|
+
const position = numeric ? Number(target) : 1
|
|
140
|
+
if (numeric && (!/^\d+$/.test(target) || !Number.isSafeInteger(position) || position < 1)) {
|
|
141
|
+
throw new Error("Choose a positive integer reply number (1 = latest, 2 = previous).")
|
|
142
|
+
}
|
|
143
|
+
const source = target && !numeric
|
|
129
144
|
? await documentSource(target, context.client, sessionID, controller.signal)
|
|
130
|
-
: await latestReply(context.client, sessionID, controller.signal)
|
|
145
|
+
: await latestReply(context.client, sessionID, controller.signal, position)
|
|
131
146
|
if (controller.signal.aborted) return
|
|
132
|
-
if (!source) return notice(
|
|
147
|
+
if (!source) return notice(position === 1
|
|
148
|
+
? "This session has no completed assistant reply yet."
|
|
149
|
+
: `Can't find reply ${position}: this session has fewer completed assistant text replies.`)
|
|
133
150
|
|
|
134
151
|
directory = await mkdtemp(path.join(options.tempRoot ?? os.tmpdir(), "opencode-annotate-"))
|
|
135
152
|
keepDraft = true
|
|
@@ -171,42 +188,42 @@ export function createReviewCommand(context, options = {}) {
|
|
|
171
188
|
})
|
|
172
189
|
const feedback = stdout.trim()
|
|
173
190
|
const archived = await archivedNotes(data)
|
|
174
|
-
const retainedArchive = archived ? `\n${archived}
|
|
191
|
+
const retainedArchive = archived ? `\n${archived} archived ${archived === 1 ? "note was" : "notes were"} kept in ${directory}.` : ""
|
|
175
192
|
// Upstream 0.9.4 emits this sentinel rather than empty stdout.
|
|
176
193
|
if (!feedback || feedback === "No annotations.") {
|
|
177
194
|
keepDraft = archived > 0
|
|
178
|
-
return notice(archived ?
|
|
195
|
+
return notice(archived ? `No annotations to send.${retainedArchive}` : "No annotations. Returned to the session.", archived ? "warning" : "info")
|
|
179
196
|
}
|
|
180
|
-
if (!feedback.startsWith("# Annotations on ")) throw new Error("
|
|
197
|
+
if (!feedback.startsWith("# Annotations on ")) throw new Error("Can't recognize the TUI annotation export format.")
|
|
181
198
|
const subject = source.filePath
|
|
182
|
-
?
|
|
183
|
-
:
|
|
199
|
+
? `Here are my annotations on the document ${JSON.stringify(source.filePath)}. Please apply the feedback to that file.\nThe annotations are based on a snapshot taken when the document was opened (SHA-256: ${source.sourceHash}). Re-read the file and check the quoted text before editing.`
|
|
200
|
+
: `Here are my annotations on your reply ${source.id}. Please address the feedback.`
|
|
184
201
|
const text = `${subject}\n\n${feedback}`
|
|
185
202
|
const feedbackPath = path.join(directory, "feedback.md")
|
|
186
203
|
await writeFile(feedbackPath, text, { mode: 0o600 })
|
|
187
204
|
const count = (feedback.match(/^## Annotation /gm) ?? []).length
|
|
188
205
|
const confirmed = await context.ui.dialog.confirm({
|
|
189
|
-
title: "
|
|
190
|
-
message: `${source.filePath ?
|
|
191
|
-
label: { confirm: "
|
|
206
|
+
title: "Send annotations?",
|
|
207
|
+
message: `${source.filePath ? `Document: ${source.filePath}\n` : ""}Send ${count} ${count === 1 ? "annotation" : "annotations"} to "${title}" and let the assistant continue?`,
|
|
208
|
+
label: { confirm: "Send and continue", cancel: "Cancel" },
|
|
192
209
|
})
|
|
193
210
|
if (controller.signal.aborted) return
|
|
194
211
|
if (confirmed !== true) {
|
|
195
212
|
keepDraft = archived > 0
|
|
196
|
-
return notice(
|
|
213
|
+
return notice(`Sending cancelled.${retainedArchive}`)
|
|
197
214
|
}
|
|
198
215
|
await context.client.session.prompt({
|
|
199
216
|
sessionID, id: promptID, text, delivery: "queue", resume: true,
|
|
200
217
|
})
|
|
201
218
|
keepDraft = archived > 0
|
|
202
|
-
notice(
|
|
219
|
+
notice(`Sent ${count} ${count === 1 ? "annotation" : "annotations"}.${retainedArchive}`, "success")
|
|
203
220
|
} catch (error) {
|
|
204
221
|
if (!disposed) {
|
|
205
222
|
const detail = error?.code === "ENOENT"
|
|
206
|
-
? "
|
|
223
|
+
? "Can't find plannotator-tui. Check that it is installed and on PATH."
|
|
207
224
|
: error instanceof Error ? error.message : String(error)
|
|
208
225
|
const recovery = keepDraft && directory
|
|
209
|
-
? `\
|
|
226
|
+
? `\nThe draft was kept in ${directory}. If it was exported, the feedback is in feedback.md.`
|
|
210
227
|
: ""
|
|
211
228
|
notice(`${detail}${recovery}`, "error")
|
|
212
229
|
}
|
package/tui.ts
CHANGED
|
@@ -14,11 +14,21 @@ export default Plugin.define({
|
|
|
14
14
|
commands: [{
|
|
15
15
|
id: "opencode-plannotator-tui.annotate",
|
|
16
16
|
title: "Annotate reply or document (TUI)",
|
|
17
|
-
description: "Annotate
|
|
17
|
+
description: "Annotate a reply (1 = latest, 2 = previous) or a file; press q, then confirm to send",
|
|
18
18
|
group: "Plannotator",
|
|
19
19
|
palette: true,
|
|
20
|
-
slash: { name: "annotate",
|
|
20
|
+
slash: { name: "annotate", arguments: true },
|
|
21
21
|
run: review.run,
|
|
22
|
+
}, {
|
|
23
|
+
id: "opencode-plannotator-tui.annotate-last",
|
|
24
|
+
title: "Annotate last reply (TUI)",
|
|
25
|
+
description: "Annotate the latest completed reply; press q, then confirm to send",
|
|
26
|
+
group: "Plannotator",
|
|
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
|