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 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` is an alias with the same optional file argument.
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 **发送并继续** (“Send and continue”) to submit the annotations. Escape or
65
- **取消** cancels. The adapter's confirmation and status messages are currently Chinese.
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.2.1",
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": ["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 } },
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": { "url": "https://github.com/nxxxsooo/opencode-plannotator-tui/issues" },
26
- "keywords": ["opencode", "opencode-plugin", "plannotator", "tui", "annotation", "markdown", "review"],
27
- "publishConfig": { "access": "public" }
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("请指定一个 Markdown 或纯文本文件(.md、.mdx、.markdown、.txt)。")
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("文件超过 2 MiB,请先选取较小的文档。")
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("文件超过 2 MiB。")
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("请使用 UTF-8 编码的文本文件。")
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(`无法读取文档「${filePath}」:${error.code === "ENOENT" ? "文件不存在。" : error.message}`)
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()) return { id: message.id, text }
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 = selectReply(page.data)
72
- if (reply) return reply
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("请在 OpenCode 完整终端界面中运行 /annotate。")
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 source = target
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} 条归档批注保留在 ${directory}。` : ""
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 ? `没有待发送的批注。${retainedArchive}` : "没有批注,已返回会话。", archived ? "warning" : "info")
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("无法识别 TUI 的批注导出格式。")
197
+ if (!feedback.startsWith("# Annotations on ")) throw new Error("Can't recognize the TUI annotation export format.")
181
198
  const subject = source.filePath
182
- ? `以下是我对文档 ${JSON.stringify(source.filePath)} 的批注,请按反馈处理该文件。\n批注基于打开时的文档快照(SHA-256:${source.sourceHash});修改前请重新读取原文件并核对引用。`
183
- : `以下是我对你回复 ${source.id} 的批注,请按反馈处理。`
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 ? `文档:${source.filePath}\n` : ""}将 ${count} 条批注发送到「${title}」,并让助手继续处理?`,
191
- label: { confirm: "发送并继续", cancel: "取消" },
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(`已取消发送。${retainedArchive}`)
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(`已发送 ${count} 条批注。${retainedArchive}`, "success")
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
- ? "找不到 plannotator-tui;请检查安装和 PATH。"
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
- ? `\n草稿保留在 ${directory};若已导出,反馈文件为 feedback.md。`
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 the last reply or a file; press q, then confirm to send",
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", aliases: ["annotate-last"], arguments: true },
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