@gavin-hu/my-pi-agent 0.20.0 → 0.21.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 (106) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/extensions/README.md +1 -0
  4. package/docs/extensions/board.md +57 -42
  5. package/docs/extensions/bytefly.md +85 -89
  6. package/docs/extensions/file-browse.md +36 -41
  7. package/docs/extensions/plan.md +31 -14
  8. package/docs/extensions/serve.md +129 -0
  9. package/docs/extensions/session-bus.md +1 -1
  10. package/docs/extensions/team.md +11 -11
  11. package/extensions/board/commands.ts +27 -14
  12. package/extensions/board/index.ts +14 -2
  13. package/extensions/board/join.ts +31 -10
  14. package/extensions/board/page.ts +53 -83
  15. package/extensions/board/result.ts +1 -24
  16. package/extensions/board/rows.ts +1 -1
  17. package/extensions/board/schema.ts +9 -3
  18. package/extensions/board/snapshot.ts +7 -6
  19. package/extensions/board/tools.ts +27 -15
  20. package/extensions/board/transport/router.ts +3 -5
  21. package/extensions/board/transport/server.ts +59 -28
  22. package/extensions/board/types.ts +0 -10
  23. package/extensions/board/view/client.ts +3 -2
  24. package/extensions/board/view/live.ts +1 -7
  25. package/extensions/board/view/page.ts +16 -5
  26. package/extensions/board/view/render.ts +22 -17
  27. package/extensions/board/view/styles.ts +7 -1
  28. package/extensions/bytefly/footer/config.ts +3 -6
  29. package/extensions/bytefly/footer/render/footer.ts +18 -4
  30. package/extensions/bytefly/footer/render/key.ts +46 -0
  31. package/extensions/bytefly/footer/render/layout.ts +13 -3
  32. package/extensions/bytefly/footer/render/lines.ts +4 -4
  33. package/extensions/bytefly/footer/render/segments.ts +10 -34
  34. package/extensions/bytefly/header/component.ts +4 -20
  35. package/extensions/check/render.ts +10 -1
  36. package/extensions/file-browse/config/config.ts +4 -19
  37. package/extensions/file-browse/index.ts +27 -48
  38. package/extensions/file-browse/live/live.ts +1 -1
  39. package/extensions/file-browse/transport/router.ts +12 -4
  40. package/extensions/file-browse/transport/server.ts +61 -28
  41. package/extensions/file-browse/view/chrome.ts +4 -4
  42. package/extensions/file-browse/view/client.ts +3 -1
  43. package/extensions/file-browse/view/html.ts +73 -40
  44. package/extensions/file-browse/view/live-client.ts +2 -2
  45. package/extensions/file-browse/view/markdown.ts +13 -14
  46. package/extensions/file-browse/view/palette.ts +2 -2
  47. package/extensions/file-browse/view/raw-html.ts +9 -9
  48. package/extensions/file-browse/view/tree.ts +1 -1
  49. package/extensions/file-browse/view/urls.ts +20 -6
  50. package/extensions/goal/tui.ts +12 -2
  51. package/extensions/job/tools.ts +19 -7
  52. package/extensions/job/tui.ts +2 -28
  53. package/extensions/plan/commands.ts +2 -2
  54. package/extensions/plan/feedback.ts +62 -16
  55. package/extensions/plan/index.ts +7 -1
  56. package/extensions/plan/plan-text.ts +114 -0
  57. package/extensions/plan/runtime.ts +12 -2
  58. package/extensions/plan/tools.ts +2 -2
  59. package/extensions/plan/web-blocks.ts +76 -5
  60. package/extensions/plan/web-chrome.ts +4 -0
  61. package/extensions/plan/web-client.ts +99 -2
  62. package/extensions/plan/web-collab.ts +4 -4
  63. package/extensions/plan/web-comments.ts +39 -8
  64. package/extensions/plan/web-composer.ts +14 -3
  65. package/extensions/plan/web-keys.ts +149 -25
  66. package/extensions/plan/web-markdown.ts +1 -1
  67. package/extensions/plan/web-outline.ts +22 -2
  68. package/extensions/plan/web-page.ts +56 -86
  69. package/extensions/plan/web-payload.ts +188 -0
  70. package/extensions/plan/web-plan.ts +23 -13
  71. package/extensions/plan/web-settle.ts +14 -7
  72. package/extensions/plan/web-share-session.ts +24 -55
  73. package/extensions/plan/web-share.ts +19 -37
  74. package/extensions/plan/web-write.ts +112 -0
  75. package/extensions/plan/web.ts +77 -130
  76. package/extensions/serve/config.ts +37 -0
  77. package/extensions/serve/index.ts +71 -0
  78. package/extensions/serve/registry.ts +251 -0
  79. package/extensions/session-bus/join-request.ts +16 -9
  80. package/extensions/session-bus/result.ts +1 -27
  81. package/extensions/session-bus/tools.ts +5 -4
  82. package/extensions/team/commands.ts +3 -1
  83. package/extensions/todo/tui.ts +21 -3
  84. package/lib/README.md +5 -4
  85. package/lib/board/page-pointer.ts +29 -4
  86. package/lib/board/session.ts +84 -18
  87. package/lib/board/store.ts +34 -8
  88. package/lib/board/view.ts +20 -14
  89. package/lib/bus-request.ts +83 -0
  90. package/lib/http/index.ts +11 -7
  91. package/lib/http/mount.ts +142 -0
  92. package/lib/http/port.ts +25 -0
  93. package/lib/http/serve.ts +170 -46
  94. package/lib/http/server.ts +3 -3
  95. package/lib/plan-entry.ts +9 -13
  96. package/lib/requirements.ts +2 -2
  97. package/lib/session-label.ts +23 -7
  98. package/lib/session-tool.ts +37 -21
  99. package/lib/tui/README.md +6 -3
  100. package/lib/tui/glyphs.ts +2 -4
  101. package/lib/tui/render-cache.ts +29 -0
  102. package/lib/tui/tool.ts +30 -1
  103. package/lib/web/client-runtime.ts +27 -1
  104. package/lib/web/markup.ts +18 -1
  105. package/package.json +3 -2
  106. package/extensions/board/config.ts +0 -36
@@ -19,7 +19,13 @@
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
+ keysDialogScript,
27
+ THEME_TOGGLE_JS,
28
+ } from "../../lib/web/index.ts";
23
29
  import { WEB_CHROME_JS } from "./web-chrome.ts";
24
30
  import { WEB_COMMENTS_JS } from "./web-comments.ts";
25
31
  import { WEB_COLLAB_JS } from "./web-collab.ts";
@@ -34,6 +40,7 @@ import { WEB_SETTLE_JS } from "./web-settle.ts";
34
40
 
35
41
  export const REVIEW_CLIENT_JS = String.raw`(function () {
36
42
  "use strict";
43
+ ${CLIENT_BASE_JS}
37
44
 
38
45
  var dataNode = document.getElementById("review-data");
39
46
  // The caps come from the island, which takes them from the server's own
@@ -67,12 +74,19 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
67
74
  var sectionObserver = null;
68
75
  var pendingQuote = null;
69
76
  var activeIndex = -1;
77
+ /** What the reader asked the plan to show: every block, the changed ones, or the annotated. */
78
+ var view = "all";
70
79
  var outlineItems = {};
71
80
  /** The island's blocks, or none: one place the client reads them for navigation. */
72
81
  function blocks() {
73
82
  return Array.isArray(data.blocks) ? data.blocks : [];
74
83
  }
75
84
 
85
+ /** The island's changed blocks, or none: the whole list is scanned, because it is short. */
86
+ function changedBlocks() {
87
+ return Array.isArray(data.changed) ? data.changed : [];
88
+ }
89
+
76
90
  /** The island's outline, or none: the one place the client reads it for the outline. */
77
91
  function outlineEntries() {
78
92
  return Array.isArray(data.outline) ? data.outline : [];
@@ -132,6 +146,7 @@ ${WEB_MARKDOWN_JS}
132
146
  planEl: planEl,
133
147
  statusEl: statusEl,
134
148
  refusal: DRAFT_REFUSAL,
149
+ settled: function () { return saved || approved || closed; },
135
150
  setStatus: setStatus,
136
151
  saveDraft: saveDraft,
137
152
  open: openComposer,
@@ -159,11 +174,13 @@ ${WEB_MARKDOWN_JS}
159
174
  outlineList: outlineList,
160
175
  focused: document.activeElement,
161
176
  blocks: blocks,
177
+ changed: function (line) { return changedBlocks().indexOf(line) >= 0; },
162
178
  annotations: function () { return annotations; },
163
179
  activeIndex: function () { return activeIndex; },
164
180
  setActiveIndex: function (index) { activeIndex = index; },
165
181
  pendingQuote: function () { return pendingQuote; },
166
182
  clearPendingQuote: function () { pendingQuote = null; },
183
+ settled: function () { return saved || approved || closed; },
167
184
  composer: composer,
168
185
  renderBlock: renderBlockContent,
169
186
  renderComments: renderComments,
@@ -175,8 +192,72 @@ ${WEB_MARKDOWN_JS}
175
192
  };
176
193
  }
177
194
 
195
+ /**
196
+ * Apply the current view and say what it hides.
197
+ *
198
+ * The count goes on the status line rather than into the document: a filtered plan with
199
+ * no note reads as a shorter plan, and the count is the one fact that undoes that.
200
+ */
201
+ function syncView() {
202
+ var counts = applyView(planEl, view);
203
+ var buttons = document.querySelectorAll("[data-view]");
204
+ for (var i = 0; i < buttons.length; i++) {
205
+ var button = buttons[i];
206
+ var name = button.getAttribute("data-view");
207
+ button.setAttribute("aria-pressed", name === view ? "true" : "false");
208
+ // Nothing to compare against is not an empty view: the button says why it is off.
209
+ if (name === "changed") {
210
+ var comparable = data.blockMarks === true;
211
+ button.disabled = !comparable;
212
+ button.title = comparable ? "Only what this review changed" : "No earlier review of this plan to compare against";
213
+ }
214
+ }
215
+ // The page's own wrapper owns the element: the shared paint takes the node first, so
216
+ // the sentence goes through setStatus rather than being handed over as one. A settled
217
+ // review's status line carries how it ended, not the filter's count.
218
+ if (counts.hidden > 0 && !saved && !approved && !closed)
219
+ setStatus("Showing " + counts.shown + " of " + (counts.shown + counts.hidden) + " blocks — the rest hidden");
220
+ }
221
+
222
+ /**
223
+ * Walk from the active block to the next one that carries a comment.
224
+ *
225
+ * The block order is the document's, so "next" means further down the plan, not the next
226
+ * comment by arrival: a reader walking notes is reading the document. A filtered view can
227
+ * hide a commented block, and a hidden one is not a place to send the reader, so the step
228
+ * skips it the way the block walk does. Nothing repaints, so no draft is at risk.
229
+ */
230
+ function moveComment(delta) {
231
+ var list = blockElements(planEl);
232
+ var start = activeIndex >= 0 ? activeIndex : delta > 0 ? -1 : list.length;
233
+ for (var step = start + delta; step >= 0 && step < list.length; step += delta) {
234
+ var block = list[step];
235
+ if (!block || block.hidden === true) continue;
236
+ if ((block.className || "").split(" ").indexOf("bf-block--commented") < 0) continue;
237
+ var next = setActive(planEl, step);
238
+ if (next !== null) activeIndex = next;
239
+ if (block.scrollIntoView) block.scrollIntoView({ block: "center" });
240
+ return;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Cycle the panel's views, skipping the one this page cannot offer.
246
+ *
247
+ * The repaint closes the composer, so a half-written comment refuses first, the way the
248
+ * view buttons do.
249
+ */
250
+ function cycleView() {
251
+ if (confirmNoDraft()) return;
252
+ var order = data.blockMarks === true ? ["all", "changed", "comments"] : ["all", "comments"];
253
+ var at = order.indexOf(view);
254
+ view = order[(at + 1) % order.length] || "all";
255
+ renderAll();
256
+ }
257
+
178
258
  function renderAll() {
179
259
  sectionObserver = renderPlanFrame(planFrame(), closeOpenComposer, observeSections, sectionObserver);
260
+ syncView();
180
261
  }
181
262
 
182
263
  // Save, approve, and post share the same POST shape; what differs is whether
@@ -199,6 +280,7 @@ ${WEB_MARKDOWN_JS}
199
280
  function settlement() {
200
281
  return {
201
282
  token: function () { return token; },
283
+ base: pageBase,
202
284
  role: function () { return role; },
203
285
  mine: function () { return annotations.filter(isMine); },
204
286
  annotations: function () { return annotations; },
@@ -319,6 +401,8 @@ ${WEB_MARKDOWN_JS}
319
401
  var next = moveActive(planEl, activeIndex, delta);
320
402
  if (next !== null) activeIndex = next;
321
403
  },
404
+ moveComment: moveComment,
405
+ cycleView: cycleView,
322
406
  commentOnActive: function () { activeIndex = commentOnActive(composer(), activeIndex, blocks()); },
323
407
  askApprove: askApprove,
324
408
  save: save,
@@ -329,6 +413,19 @@ ${WEB_MARKDOWN_JS}
329
413
  };
330
414
  }
331
415
 
416
+ var viewButtons = document.querySelectorAll("[data-view]");
417
+ for (var v = 0; v < viewButtons.length; v++) {
418
+ viewButtons[v].addEventListener("click", function (event) {
419
+ var next = event.target && event.target.getAttribute ? event.target.getAttribute("data-view") : null;
420
+ if (next !== "all" && next !== "changed" && next !== "comments") return;
421
+ // The filter repaints the document, which closes the composer: a half-written
422
+ // comment would go with it, so the click refuses the way the ways off the page do.
423
+ if (confirmNoDraft()) return;
424
+ view = next;
425
+ renderAll();
426
+ });
427
+ }
428
+
332
429
  document.addEventListener("keydown", function (event) {
333
430
  handleKey(event, keyActions());
334
431
  });
@@ -344,7 +441,7 @@ ${WEB_MARKDOWN_JS}
344
441
  if (planEl) planEl.addEventListener("scroll", updateProgress);
345
442
 
346
443
  try {
347
- stream = new EventSource("/events?token=" + encodeURIComponent(token));
444
+ stream = new EventSource(pageBase() + "/events?token=" + encodeURIComponent(token));
348
445
  stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
349
446
  stream.addEventListener("message", function (event) { refreshFromServer(event); });
350
447
  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");
@@ -199,7 +199,7 @@ export const WEB_MARKDOWN_JS = String.raw` // A link is rendered only for an ht
199
199
  var markerText = match[2];
200
200
  var rest = match[3];
201
201
  var box = /^\[([ xX])\]\s*(.*)$/.exec(rest);
202
- var marker = el("span", "marker");
202
+ var marker = el("span", "bf-block__marker");
203
203
  if (box) {
204
204
  var done = box[1].toLowerCase() === "x";
205
205
  markerText = done ? "\u2713" : "\u2610";
@@ -23,6 +23,8 @@ export interface OutlineEntry {
23
23
  line: number;
24
24
  text: string;
25
25
  changed?: boolean;
26
+ /** Content blocks this section no longer holds, when the baseline can say so. */
27
+ removed?: number;
26
28
  }
27
29
 
28
30
  /** What the browser's `IntersectionObserver` answers for a section watcher. */
@@ -73,6 +75,14 @@ export function renderOutline(list: ClientNode | null, entries: readonly Outline
73
75
  dot.title = "Changed since your last review";
74
76
  link.appendChild(dot);
75
77
  }
78
+ if (item.removed !== undefined && item.removed > 0) {
79
+ // A block that left cannot be drawn where it was, so the count is the fact the
80
+ // page can still give: how much shorter this section has become.
81
+ link.title =
82
+ item.removed === 1
83
+ ? "1 block removed since your last review"
84
+ : item.removed + " blocks removed since your last review";
85
+ }
76
86
  const count = el("span", "review-nav__count");
77
87
  count.hidden = true;
78
88
  link.appendChild(count);
@@ -137,6 +147,8 @@ export function observeSections(
137
147
  /** One review comment, as the facts need it: the line it is anchored to. */
138
148
  export interface AnchoredComment {
139
149
  anchor: number;
150
+ /** A settled note counts in the section's thread but not among the open ones. */
151
+ resolved?: true;
140
152
  }
141
153
 
142
154
  /** What a plan's step list says: the numbers the server counted. */
@@ -170,9 +182,17 @@ export function renderFacts(
170
182
  const fill = doc.getElementById("step-fill");
171
183
  if (fill) fill.style.width = (steps.total > 0 ? Math.round((steps.done / steps.total) * 100) : 0) + "%";
172
184
  const count = doc.getElementById("step-count");
173
- if (count) count.textContent = steps.done + "/" + steps.total;
185
+ // Named for what it counts: before approval these ticks are the plan's ambition, not
186
+ // work done, and after it completion belongs to `todo`.
187
+ if (count) count.textContent = steps.total === 0 ? "" : steps.done + "/" + steps.total + " ticked";
174
188
  const total = doc.getElementById("comment-total");
175
- if (total) total.textContent = String(comments.length);
189
+ if (total) {
190
+ // Open first, because a settled note is history: "3 open · 2 resolved" says what is
191
+ // left to answer, and the resolved half stays because the file keeps it.
192
+ const settled = comments.filter((comment) => comment.resolved === true).length;
193
+ const open = comments.length - settled;
194
+ total.textContent = settled === 0 ? String(open) : open + " open · " + settled + " resolved";
195
+ }
176
196
  for (const line of Object.keys(counters)) {
177
197
  const node = counters[Number(line)];
178
198
  if (node) node.hidden = true;