@gavin-hu/my-pi-agent 0.20.0 → 0.22.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.
Files changed (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -19,7 +19,14 @@
19
19
  * saved back to the server.
20
20
  */
21
21
 
22
- import { CLIENT_RUNTIME_JS, dialogScript, keysDialogScript, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
22
+ import {
23
+ CLIENT_BASE_JS,
24
+ CLIENT_RUNTIME_JS,
25
+ dialogScript,
26
+ INLINE_MARKDOWN_JS,
27
+ keysDialogScript,
28
+ THEME_TOGGLE_JS,
29
+ } from "../../lib/web/index.ts";
23
30
  import { WEB_CHROME_JS } from "./web-chrome.ts";
24
31
  import { WEB_COMMENTS_JS } from "./web-comments.ts";
25
32
  import { WEB_COLLAB_JS } from "./web-collab.ts";
@@ -34,6 +41,7 @@ import { WEB_SETTLE_JS } from "./web-settle.ts";
34
41
 
35
42
  export const REVIEW_CLIENT_JS = String.raw`(function () {
36
43
  "use strict";
44
+ ${CLIENT_BASE_JS}
37
45
 
38
46
  var dataNode = document.getElementById("review-data");
39
47
  // The caps come from the island, which takes them from the server's own
@@ -67,12 +75,19 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
67
75
  var sectionObserver = null;
68
76
  var pendingQuote = null;
69
77
  var activeIndex = -1;
78
+ /** What the reader asked the plan to show: every block, the changed ones, or the annotated. */
79
+ var view = "all";
70
80
  var outlineItems = {};
71
81
  /** The island's blocks, or none: one place the client reads them for navigation. */
72
82
  function blocks() {
73
83
  return Array.isArray(data.blocks) ? data.blocks : [];
74
84
  }
75
85
 
86
+ /** The island's changed blocks, or none: the whole list is scanned, because it is short. */
87
+ function changedBlocks() {
88
+ return Array.isArray(data.changed) ? data.changed : [];
89
+ }
90
+
76
91
  /** The island's outline, or none: the one place the client reads it for the outline. */
77
92
  function outlineEntries() {
78
93
  return Array.isArray(data.outline) ? data.outline : [];
@@ -111,6 +126,8 @@ ${WEB_COMMENTS_JS}
111
126
 
112
127
  ${WEB_COLLAB_JS}
113
128
 
129
+ ${INLINE_MARKDOWN_JS}
130
+
114
131
  ${WEB_MARKDOWN_JS}
115
132
 
116
133
  // Review facts: the plan's step progress, and the comments per section.
@@ -132,6 +149,7 @@ ${WEB_MARKDOWN_JS}
132
149
  planEl: planEl,
133
150
  statusEl: statusEl,
134
151
  refusal: DRAFT_REFUSAL,
152
+ settled: function () { return saved || approved || closed; },
135
153
  setStatus: setStatus,
136
154
  saveDraft: saveDraft,
137
155
  open: openComposer,
@@ -159,11 +177,13 @@ ${WEB_MARKDOWN_JS}
159
177
  outlineList: outlineList,
160
178
  focused: document.activeElement,
161
179
  blocks: blocks,
180
+ changed: function (line) { return changedBlocks().indexOf(line) >= 0; },
162
181
  annotations: function () { return annotations; },
163
182
  activeIndex: function () { return activeIndex; },
164
183
  setActiveIndex: function (index) { activeIndex = index; },
165
184
  pendingQuote: function () { return pendingQuote; },
166
185
  clearPendingQuote: function () { pendingQuote = null; },
186
+ settled: function () { return saved || approved || closed; },
167
187
  composer: composer,
168
188
  renderBlock: renderBlockContent,
169
189
  renderComments: renderComments,
@@ -175,8 +195,72 @@ ${WEB_MARKDOWN_JS}
175
195
  };
176
196
  }
177
197
 
198
+ /**
199
+ * Apply the current view and say what it hides.
200
+ *
201
+ * The count goes on the status line rather than into the document: a filtered plan with
202
+ * no note reads as a shorter plan, and the count is the one fact that undoes that.
203
+ */
204
+ function syncView() {
205
+ var counts = applyView(planEl, view);
206
+ var buttons = document.querySelectorAll("[data-view]");
207
+ for (var i = 0; i < buttons.length; i++) {
208
+ var button = buttons[i];
209
+ var name = button.getAttribute("data-view");
210
+ button.setAttribute("aria-pressed", name === view ? "true" : "false");
211
+ // Nothing to compare against is not an empty view: the button says why it is off.
212
+ if (name === "changed") {
213
+ var comparable = data.blockMarks === true;
214
+ button.disabled = !comparable;
215
+ button.title = comparable ? "Only what this review changed" : "No earlier review of this plan to compare against";
216
+ }
217
+ }
218
+ // The page's own wrapper owns the element: the shared paint takes the node first, so
219
+ // the sentence goes through setStatus rather than being handed over as one. A settled
220
+ // review's status line carries how it ended, not the filter's count.
221
+ if (counts.hidden > 0 && !saved && !approved && !closed)
222
+ setStatus("Showing " + counts.shown + " of " + (counts.shown + counts.hidden) + " blocks — the rest hidden");
223
+ }
224
+
225
+ /**
226
+ * Walk from the active block to the next one that carries a comment.
227
+ *
228
+ * The block order is the document's, so "next" means further down the plan, not the next
229
+ * comment by arrival: a reader walking notes is reading the document. A filtered view can
230
+ * hide a commented block, and a hidden one is not a place to send the reader, so the step
231
+ * skips it the way the block walk does. Nothing repaints, so no draft is at risk.
232
+ */
233
+ function moveComment(delta) {
234
+ var list = blockElements(planEl);
235
+ var start = activeIndex >= 0 ? activeIndex : delta > 0 ? -1 : list.length;
236
+ for (var step = start + delta; step >= 0 && step < list.length; step += delta) {
237
+ var block = list[step];
238
+ if (!block || block.hidden === true) continue;
239
+ if ((block.className || "").split(" ").indexOf("bf-block--commented") < 0) continue;
240
+ var next = setActive(planEl, step);
241
+ if (next !== null) activeIndex = next;
242
+ if (block.scrollIntoView) block.scrollIntoView({ block: "center" });
243
+ return;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Cycle the panel's views, skipping the one this page cannot offer.
249
+ *
250
+ * The repaint closes the composer, so a half-written comment refuses first, the way the
251
+ * view buttons do.
252
+ */
253
+ function cycleView() {
254
+ if (confirmNoDraft()) return;
255
+ var order = data.blockMarks === true ? ["all", "changed", "comments"] : ["all", "comments"];
256
+ var at = order.indexOf(view);
257
+ view = order[(at + 1) % order.length] || "all";
258
+ renderAll();
259
+ }
260
+
178
261
  function renderAll() {
179
262
  sectionObserver = renderPlanFrame(planFrame(), closeOpenComposer, observeSections, sectionObserver);
263
+ syncView();
180
264
  }
181
265
 
182
266
  // Save, approve, and post share the same POST shape; what differs is whether
@@ -199,6 +283,7 @@ ${WEB_MARKDOWN_JS}
199
283
  function settlement() {
200
284
  return {
201
285
  token: function () { return token; },
286
+ base: pageBase,
202
287
  role: function () { return role; },
203
288
  mine: function () { return annotations.filter(isMine); },
204
289
  annotations: function () { return annotations; },
@@ -319,6 +404,8 @@ ${WEB_MARKDOWN_JS}
319
404
  var next = moveActive(planEl, activeIndex, delta);
320
405
  if (next !== null) activeIndex = next;
321
406
  },
407
+ moveComment: moveComment,
408
+ cycleView: cycleView,
322
409
  commentOnActive: function () { activeIndex = commentOnActive(composer(), activeIndex, blocks()); },
323
410
  askApprove: askApprove,
324
411
  save: save,
@@ -329,6 +416,19 @@ ${WEB_MARKDOWN_JS}
329
416
  };
330
417
  }
331
418
 
419
+ var viewButtons = document.querySelectorAll("[data-view]");
420
+ for (var v = 0; v < viewButtons.length; v++) {
421
+ viewButtons[v].addEventListener("click", function (event) {
422
+ var next = event.target && event.target.getAttribute ? event.target.getAttribute("data-view") : null;
423
+ if (next !== "all" && next !== "changed" && next !== "comments") return;
424
+ // The filter repaints the document, which closes the composer: a half-written
425
+ // comment would go with it, so the click refuses the way the ways off the page do.
426
+ if (confirmNoDraft()) return;
427
+ view = next;
428
+ renderAll();
429
+ });
430
+ }
431
+
332
432
  document.addEventListener("keydown", function (event) {
333
433
  handleKey(event, keyActions());
334
434
  });
@@ -344,7 +444,7 @@ ${WEB_MARKDOWN_JS}
344
444
  if (planEl) planEl.addEventListener("scroll", updateProgress);
345
445
 
346
446
  try {
347
- stream = new EventSource("/events?token=" + encodeURIComponent(token));
447
+ stream = new EventSource(pageBase() + "/events?token=" + encodeURIComponent(token));
348
448
  stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
349
449
  stream.addEventListener("message", function (event) { refreshFromServer(event); });
350
450
  stream.addEventListener("error", function () {
@@ -88,19 +88,19 @@ export const WEB_COLLAB_JS = String.raw` /**
88
88
  }
89
89
 
90
90
  function shareNow() {
91
- shareAction("/api/share", null, "Could not share: ");
91
+ shareAction(pageBase() + "/api/share", null, "Could not share: ");
92
92
  }
93
93
 
94
94
  function newLink() {
95
- shareAction("/api/share/link", null, "Could not mint a link: ");
95
+ shareAction(pageBase() + "/api/share/link", null, "Could not mint a link: ");
96
96
  }
97
97
 
98
98
  function revokeLink(id) {
99
- shareAction("/api/share/revoke", { id: id }, "Could not revoke that link: ");
99
+ shareAction(pageBase() + "/api/share/revoke", { id: id }, "Could not revoke that link: ");
100
100
  }
101
101
 
102
102
  function stopSharing() {
103
- fetch("/api/share/stop?token=" + encodeURIComponent(token), { method: "POST" })
103
+ fetch(pageBase() + "/api/share/stop?token=" + encodeURIComponent(token), { method: "POST" })
104
104
  .then(function (response) { return response.json(); })
105
105
  .then(function (payload) {
106
106
  if (payload && payload.share) showShare(payload.share, null);
@@ -19,6 +19,8 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
19
19
  var next = wrap.nextElementSibling;
20
20
  if (next && next.classList && next.classList.contains("bf-comments")) return next;
21
21
  var box = el("div", "bf-comments");
22
+ // The line it belongs to, so a filtered view can hide the thread with its block.
23
+ box.setAttribute("data-line", wrap.getAttribute("data-line") || "");
22
24
  wrap.insertAdjacentElement("afterend", box);
23
25
  return box;
24
26
  }
@@ -112,6 +114,10 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
112
114
  }
113
115
 
114
116
  function openComposer(wrap, block, quote, initial) {
117
+ // One composer at a time, and the open one holds text the page has not committed:
118
+ // replacing it would drop that text without a word. A restored draft passes its text
119
+ // as the initial value, because it is the thing being reopened, not a draft to protect.
120
+ if (!initial && guardDraft(planEl, statusEl, DRAFT_REFUSAL, paintStatus)) return;
115
121
  closeOpenComposer();
116
122
  wrap.classList.add("bf-block--composing");
117
123
  var composer = el("div", "bf-composer");
@@ -135,7 +141,9 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
135
141
  cancel.addEventListener("click", closeOpenComposer);
136
142
  var add = el("button"); add.type = "button"; add.className = "bf-btn bf-btn--primary"; add.textContent = "Add comment";
137
143
  var submit = function () {
138
- if (closed) { setStatus("The review is closed; this comment was not added.", "error"); return; }
144
+ // A settled review takes no new comment: the note could never reach the plan, so
145
+ // adding it would report "Comment added" about something nobody will read.
146
+ if (saved || approved || closed) { setStatus("This review has ended; the comment was not added.", "error"); return; }
139
147
  if (role === "reviewer" && !reviewerName) { askName(); return; }
140
148
  var comment = area.value.replace(/\s+/g, " ").trim();
141
149
  if (!comment) { area.focus(); return; }
@@ -232,10 +240,12 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
232
240
  function renderCommentCards(box, list) {
233
241
  list.forEach(function (annotation) {
234
242
  var index = annotations.indexOf(annotation);
235
- var card = el("div", "bf-comment");
243
+ var card = el("div", annotation.resolved ? "bf-comment bf-comment--resolved" : "bf-comment");
236
244
  var head = el("div", "bf-comment__head");
237
- var label = el("span", annotation.author ? "bf-comment__label bf-comment__label--author" : "bf-comment__label");
238
- label.textContent = annotation.author ? "@" + annotation.author : "Review";
245
+ // Every card names a person: "Review" was a category, and on a reviewer's page it
246
+ // could be anybody — including the reader.
247
+ var label = el("span", "bf-comment__label bf-comment__label--author");
248
+ label.textContent = annotation.author ? "@" + annotation.author : role === "owner" ? "You" : "The owner";
239
249
  head.appendChild(label);
240
250
  if (annotation.moved) {
241
251
  // A refinement carried this note off the text it was written on: the placement is
@@ -247,14 +257,31 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
247
257
  "Written on an earlier revision. The block it was on changed, so it sits here now: check it still refers to the text below.";
248
258
  head.appendChild(moved);
249
259
  }
260
+ if (annotation.resolved) {
261
+ // Settled notes keep their thread and their quote, and lose their body: the record
262
+ // stays, the reader's eye lands on what is still open.
263
+ var settled = el("span", "bf-comment__resolved");
264
+ settled.textContent = "\u2713 settled";
265
+ settled.title = "Settled: kept in the plan as the record of the question";
266
+ head.appendChild(settled);
267
+ }
250
268
  head.appendChild(el("span", "bf-comment__spacer"));
251
269
  if (isMine(annotation)) {
252
270
  var actions = el("div", "bf-comment__actions");
253
271
  var edit = el("button"); edit.type = "button"; edit.className = "bf-btn"; edit.textContent = "Edit";
254
272
  edit.addEventListener("click", function () { startEdit(card, index); });
273
+ var settle = el("button"); settle.type = "button"; settle.className = "bf-btn";
274
+ settle.textContent = annotation.resolved ? "Reopen" : "Resolve";
275
+ settle.addEventListener("click", function () {
276
+ if (annotation.resolved) delete annotation.resolved;
277
+ else annotation.resolved = true;
278
+ markDirty();
279
+ renderAll();
280
+ setStatus(annotation.resolved ? "Comment resolved." : "Comment reopened.");
281
+ });
255
282
  var remove = el("button"); remove.type = "button"; remove.className = "bf-btn"; remove.textContent = "Delete";
256
283
  remove.addEventListener("click", function () { removeComment(index); });
257
- actions.appendChild(edit); actions.appendChild(remove); head.appendChild(actions);
284
+ actions.appendChild(settle); actions.appendChild(edit); actions.appendChild(remove); head.appendChild(actions);
258
285
  }
259
286
  card.appendChild(head);
260
287
  if (annotation.quote) {
@@ -262,9 +289,11 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
262
289
  quote.textContent = "\u201c" + annotation.quote + "\u201d";
263
290
  card.appendChild(quote);
264
291
  }
265
- var body = el("div", "bf-comment__body");
266
- body.textContent = annotation.comment;
267
- card.appendChild(body);
292
+ if (!annotation.resolved) {
293
+ var body = el("div", "bf-comment__body");
294
+ body.textContent = annotation.comment;
295
+ card.appendChild(body);
296
+ }
268
297
  box.appendChild(card);
269
298
  });
270
299
  }
@@ -273,6 +302,8 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
273
302
  var mine = annotations.filter(function (annotation) { return annotation.anchor === anchor; });
274
303
  if (mine.length === 0) return;
275
304
  var box = el("div", "bf-comments");
305
+ // The block this thread hangs under, as a line: the view filter hides the two together.
306
+ box.setAttribute("data-line", String(anchor));
276
307
  renderCommentCards(box, mine);
277
308
  container.appendChild(box);
278
309
  }
@@ -21,16 +21,22 @@ export interface ComposerDeps {
21
21
  statusEl: ClientNode | null;
22
22
  /** The sentence that names a half-written comment, so a stale one can be cleared. */
23
23
  refusal: string;
24
+ /** Whether the review has ended, so a key opens no composer for it. */
25
+ settled?(): boolean;
24
26
  setStatus(text: string, kind?: string): void;
25
27
  saveDraft(): void;
26
28
  open: OpenComposer;
27
29
  }
28
30
 
29
31
  /**
30
- * Close the composer, and let the stored draft follow it.
32
+ * Close the composer, and drop the text it was holding.
31
33
  *
32
- * A close with nothing open must not rewrite — and so drop — a draft a reload restored, so
33
- * the draft is saved only when a composer was actually there.
34
+ * A close is the reader's Cancel or Esc, or the commit that turned the text into a
35
+ * comment: either way the composer is not somewhere to resume from, so the draft does not
36
+ * keep it. What protects a half-written comment is the guards on the ways out — a repaint
37
+ * that would close a composer asks first (`guardDraft`), the view control included — not
38
+ * this function. A close with nothing open must not rewrite, and so drop, a draft a reload
39
+ * restored.
34
40
  */
35
41
  export function closeComposer(deps: ComposerDeps): void {
36
42
  const open = deps.planEl ? deps.planEl.querySelector(".bf-composer") : null;
@@ -40,6 +46,8 @@ export function closeComposer(deps: ComposerDeps): void {
40
46
  // The only status a composer leaves behind is the refusal to leave it; once the draft is
41
47
  // gone that sentence is stale.
42
48
  if (deps.statusEl && deps.statusEl.textContent === deps.refusal) deps.setStatus("");
49
+ // The composer is gone, so recomputing the draft now is what forgets the text it held —
50
+ // a close is not a pause — while the comments this writer added stay in it.
43
51
  if (open) deps.saveDraft();
44
52
  }
45
53
 
@@ -67,6 +75,9 @@ export function reopenComposer(
67
75
  * navigation, and the block the reader lands on is the block they are commenting on.
68
76
  */
69
77
  export function commentOnActive(deps: ComposerDeps, activeIndex: number, blocks: readonly IslandBlock[]): number {
78
+ // A settled review takes no new comment, so the key opens nothing rather than a
79
+ // composer whose note could never be saved.
80
+ if (deps.settled?.() === true) return activeIndex;
70
81
  if (blockElements(deps.planEl).length === 0) return activeIndex;
71
82
  const index = activeIndex < 0 ? (setActive(deps.planEl, 0) ?? activeIndex) : activeIndex;
72
83
  const wrap = blockElements(deps.planEl)[Math.max(0, index)];
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import type { ClientNode } from "../../lib/web/client-runtime.ts";
20
+ import type { ReviewRole } from "./types.ts";
20
21
 
21
22
  /** What a key may do: one callback per action the page owns. */
22
23
  export interface KeyDeps {
@@ -30,6 +31,10 @@ export interface KeyDeps {
30
31
  toggleSheet(): void;
31
32
  /** Walk the blocks by one, in the direction the key names. */
32
33
  moveActive(delta: number): void;
34
+ /** Walk the blocks that carry a comment, in the direction the key names. */
35
+ moveComment?(delta: number): void;
36
+ /** Cycle what the page shows: everything, the changes, the comments. */
37
+ cycleView?(): void;
33
38
  /** Comment on the block the keyboard is on. */
34
39
  commentOnActive(): void;
35
40
  /** Approve, through its confirmation; and the two writes `s` can stand for. */
@@ -53,11 +58,142 @@ export interface KeyEvent {
53
58
  preventDefault(): void;
54
59
  }
55
60
 
61
+ /** What the page offers, for the rows whose sentence or availability depends on it. */
62
+ export interface KeySurface {
63
+ /** Whose page it is: the owner saves and closes, a reviewer posts. */
64
+ role: ReviewRole;
65
+ /** Whether this review may approve the plan. */
66
+ canApprove: boolean;
67
+ /** Whether the page wired the comment walk (`n`/`N`), so the sheet may name it. */
68
+ hasCommentWalk: boolean;
69
+ /** Whether the page wired the view cycle (`v`). */
70
+ hasViewCycle: boolean;
71
+ }
72
+
73
+ /**
74
+ * One key the page answers and documents.
75
+ *
76
+ * The bindings, the shortcut sheet and the sidebar hint all read this table, because
77
+ * three hand-kept lists of the same keys is how a sheet comes to name a key the page no
78
+ * longer answers. The two layer keys — `Esc` and `?` — carry no `run`: they are handled
79
+ * before the table, because they work while a composer or the sheet owns the keyboard,
80
+ * and they are listed so the sheet documents them with the rest.
81
+ */
82
+ export interface WebKey {
83
+ /** Stable id: the sheet's order, and what a test names. */
84
+ id: "approve" | "blocks" | "comments" | "comment" | "add" | "view" | "settle" | "close" | "sheet";
85
+ /** The keys as the sheet prints them; the same strings the handler matches. */
86
+ keys: readonly string[];
87
+ /** The sheet's sentence on this page. */
88
+ label(surface: KeySurface): string;
89
+ /** The words the sidebar hint adds after the key, if it names the key at all. */
90
+ hint?(surface: KeySurface): string;
91
+ /** Whether the page offers it; a key nobody can press is not documented. */
92
+ offered?(surface: KeySurface): boolean;
93
+ /** What the key does, for the keys this handler owns; `key` is which binding fired. */
94
+ run?(deps: KeyDeps, key: string): void;
95
+ }
96
+
97
+ /**
98
+ * The page's keys, in the order the sheet lists them.
99
+ *
100
+ * A function rather than a constant because the bundle carries it the way it carries
101
+ * every other rule here: `String()` of a body. A table spliced from data would lose the
102
+ * closures on `label`, `hint`, `offered`, and `run`, and a table the bundle does not
103
+ * carry is a keymap that throws on the first press.
104
+ */
105
+ export function webKeys(): readonly WebKey[] {
106
+ return [
107
+ {
108
+ id: "approve",
109
+ keys: ["a"],
110
+ offered: (surface) => surface.canApprove,
111
+ label: () => "Approve and run the plan",
112
+ hint: () => "to approve",
113
+ run: (deps) => deps.askApprove(),
114
+ },
115
+ {
116
+ id: "blocks",
117
+ keys: ["j", "k"],
118
+ label: () => "Next and previous block",
119
+ hint: () => "to move",
120
+ // The block keys share a row: `j` walks forward, `k` back.
121
+ run: (deps, key) => deps.moveActive(key === "k" ? -1 : 1),
122
+ },
123
+ {
124
+ id: "comments",
125
+ keys: ["n", "N"],
126
+ // Offered only when the page wired the walk: a documented key that does nothing is
127
+ // worse than a key the sheet leaves out. No `hint`: the sidebar line names what a
128
+ // reader reaches for first and leaves the rest to the sheet, or it grows forever.
129
+ offered: (surface) => surface.hasCommentWalk,
130
+ label: () => "Next and previous block with a comment",
131
+ run: (deps, key) => deps.moveComment?.(key === "N" ? -1 : 1),
132
+ },
133
+ {
134
+ id: "comment",
135
+ keys: ["c"],
136
+ label: () => "Comment on the current block",
137
+ hint: () => "to comment",
138
+ run: (deps) => deps.commentOnActive(),
139
+ },
140
+ {
141
+ id: "add",
142
+ keys: ["Enter"],
143
+ label: () => "Add the comment you are writing",
144
+ },
145
+ {
146
+ id: "view",
147
+ keys: ["v"],
148
+ offered: (surface) => surface.hasViewCycle,
149
+ label: () => "Show everything, what changed, or the comments",
150
+ run: (deps) => deps.cycleView?.(),
151
+ },
152
+ {
153
+ id: "settle",
154
+ keys: ["s"],
155
+ label: (surface) =>
156
+ surface.role === "owner" ? "Save feedback and return to Pi" : "Post your comments to the plan",
157
+ hint: (surface) => (surface.role === "owner" ? "to save" : "to post"),
158
+ run: (deps) => (deps.hasSaveControl() ? deps.save() : deps.postComments()),
159
+ },
160
+ {
161
+ id: "close",
162
+ keys: ["Esc"],
163
+ label: (surface) =>
164
+ surface.role === "owner" ? "Close the composer, the sheet, or the review" : "Close the composer or this sheet",
165
+ },
166
+ {
167
+ id: "sheet",
168
+ keys: ["?"],
169
+ label: () => "Toggle this sheet",
170
+ hint: () => "for all shortcuts",
171
+ },
172
+ ];
173
+ }
174
+
175
+ /** The rows this page offers, in sheet order. */
176
+ export function keyRows(surface: KeySurface): WebKey[] {
177
+ return webKeys().filter((row) => row.offered === undefined || row.offered(surface));
178
+ }
179
+
180
+ /** The surface a handler sees, read off the callbacks and controls it was given. */
181
+ function surfaceOf(deps: KeyDeps): KeySurface {
182
+ return {
183
+ role: deps.hasSaveControl() ? "owner" : "reviewer",
184
+ canApprove: deps.hasApproveControl(),
185
+ hasCommentWalk: deps.moveComment !== undefined,
186
+ hasViewCycle: deps.cycleView !== undefined,
187
+ };
188
+ }
189
+
56
190
  /**
57
191
  * Handle one keydown.
58
192
  *
59
193
  * A modified key is never a shortcut: `ctrl+s` belongs to the browser. A field owns the
60
- * keyboard while it has focus, and so does the shortcut sheet while it is open.
194
+ * keyboard while it has focus, and so does the shortcut sheet while it is open. The
195
+ * content keys come from {@link webKeys}, so the key that acts and the key the sheet
196
+ * documents cannot part company.
61
197
  */
62
198
  export function handleKey(event: KeyEvent, deps: KeyDeps): void {
63
199
  if (event.key === "Escape") {
@@ -78,31 +214,13 @@ export function handleKey(event: KeyEvent, deps: KeyDeps): void {
78
214
  }
79
215
  // While the shortcut sheet is open it owns the keyboard.
80
216
  if (deps.sheetOpen()) return;
81
- if (event.key === "j") {
82
- event.preventDefault();
83
- deps.moveActive(1);
84
- return;
85
- }
86
- if (event.key === "k") {
217
+ const surface = surfaceOf(deps);
218
+ for (const row of keyRows(surface)) {
219
+ if (row.run === undefined || !row.keys.includes(event.key)) continue;
87
220
  event.preventDefault();
88
- deps.moveActive(-1);
221
+ row.run(deps, event.key);
89
222
  return;
90
223
  }
91
- if (event.key === "c") {
92
- event.preventDefault();
93
- deps.commentOnActive();
94
- return;
95
- }
96
- if (event.key === "a" && deps.hasApproveControl()) {
97
- event.preventDefault();
98
- deps.askApprove();
99
- return;
100
- }
101
- if (event.key === "s") {
102
- event.preventDefault();
103
- if (deps.hasSaveControl()) deps.save();
104
- else deps.postComments();
105
- }
106
224
  }
107
225
 
108
226
  /** What remembering a selection reads, and where it puts it. */
@@ -130,5 +248,11 @@ export function rememberSelection(target: unknown, deps: QuoteDeps): void {
130
248
  deps.setPending(wrap && quote ? { wrap, quote } : null);
131
249
  }
132
250
 
133
- /** The keymap and the quote rule as source, for the client bundle. */
134
- export const WEB_KEYS_JS = [handleKey, rememberSelection].map(String).join("\n\n");
251
+ /**
252
+ * The keymap and the quote rule as source, for the client bundle.
253
+ *
254
+ * Every name `handleKey` reads has to travel with it — the table, the rows it offers,
255
+ * and the surface those rows are offered for — or the first press throws instead of
256
+ * matching a row.
257
+ */
258
+ export const WEB_KEYS_JS = [handleKey, surfaceOf, keyRows, webKeys, rememberSelection].map(String).join("\n\n");
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The plan review server, loaded when a review first asks for it.
3
+ *
4
+ * The browser review owns `lib/http`, the web kit, and the plan page; none of it
5
+ * is needed to register `/plan` or the plan tools. `index.ts`, `commands.ts`, and
6
+ * `tools.ts` therefore share this proxy over `lib/lazy`: the first review loads
7
+ * `web.ts` once, and `closeAll` on shutdown only closes a review that was already
8
+ * opened, so a session that never reviewed a plan imports none of the server.
9
+ *
10
+ * The synchronous reads (`current`, `shareState`) answer `undefined` until the
11
+ * module is loaded, which is the truth: no review can be open before a load.
12
+ */
13
+
14
+ import { lazy } from "../../lib/lazy.ts";
15
+ import type { PlanStore } from "./plans.ts";
16
+ import type { PlanWebReview, PlanWebReviewDeps } from "./web.ts";
17
+
18
+ /** The review module, as the proxy loads it. */
19
+ export type PlanWebModule = typeof import("./web.ts");
20
+
21
+ /** How the proxy reaches the review module; production uses the dynamic import. */
22
+ export type PlanWebLoader = () => Promise<PlanWebModule>;
23
+
24
+ /**
25
+ * The deps a caller that injects its own review leaves out.
26
+ *
27
+ * A review needs a place on the session's server, so a caller with no wiring says so
28
+ * rather than binding a socket of its own: the start is refused with a sentence.
29
+ */
30
+ export const NO_SERVER_DEPS: PlanWebReviewDeps = {
31
+ mount: async () => ({ kind: "refused", reason: "this review has no server wired" }),
32
+ session: () => "",
33
+ };
34
+
35
+ export function createLazyPlanWebReview(
36
+ store: PlanStore,
37
+ deps: PlanWebReviewDeps,
38
+ load: PlanWebLoader = () => import("./web.ts"),
39
+ ): PlanWebReview {
40
+ const review = lazy(async () => (await load()).createPlanWebReview(store, deps));
41
+ return {
42
+ start: async (cwd, plan, options) => (await review.get()).start(cwd, plan, options),
43
+ current: () => review.peek()?.current(),
44
+ share: async () => (await review.get()).share(),
45
+ stopSharing: async () => (await review.get()).stopSharing(),
46
+ shareState: () => review.peek()?.shareState(),
47
+ // Shutdown must not start a load: a review that never opened has nothing to close.
48
+ closeAll: async () => {
49
+ const opened = review.peek();
50
+ if (opened !== undefined) await opened.closeAll();
51
+ },
52
+ };
53
+ }