@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -112,7 +112,7 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
112
112
  }
113
113
 
114
114
  function openComposer(wrap, block, quote, initial) {
115
- closeComposer();
115
+ closeOpenComposer();
116
116
  wrap.classList.add("bf-block--composing");
117
117
  var composer = el("div", "bf-composer");
118
118
  if (quote) {
@@ -132,7 +132,7 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
132
132
  composer.appendChild(area);
133
133
  var row = el("div", "bf-composer__row");
134
134
  var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
135
- cancel.addEventListener("click", closeComposer);
135
+ cancel.addEventListener("click", closeOpenComposer);
136
136
  var add = el("button"); add.type = "button"; add.className = "bf-btn bf-btn--primary"; add.textContent = "Add comment";
137
137
  var submit = function () {
138
138
  if (closed) { setStatus("The review is closed; this comment was not added.", "error"); return; }
@@ -144,10 +144,10 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
144
144
  if (role === "reviewer") entry.author = reviewerName;
145
145
  annotations.push(entry);
146
146
  // Close first: the text is now a comment, so the draft keeps one or the other.
147
- closeComposer();
147
+ closeOpenComposer();
148
148
  markDirty();
149
149
  setStatus("Comment added.");
150
- renderPlan();
150
+ renderAll();
151
151
  };
152
152
  add.addEventListener("click", submit);
153
153
  submitOnEnter(area, submit);
@@ -168,7 +168,7 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
168
168
  card.appendChild(area);
169
169
  var row = el("div", "bf-comment__row");
170
170
  var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
171
- cancel.addEventListener("click", renderPlan);
171
+ cancel.addEventListener("click", renderAll);
172
172
  var apply = el("button"); apply.type = "button"; apply.className = "bf-btn bf-btn--primary"; apply.textContent = "Save";
173
173
  apply.addEventListener("click", function () {
174
174
  var comment = area.value.replace(/\s+/g, " ").trim();
@@ -176,7 +176,7 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
176
176
  annotation.comment = comment;
177
177
  markDirty();
178
178
  setStatus("Comment updated.");
179
- renderPlan();
179
+ renderAll();
180
180
  });
181
181
  submitOnEnter(area, function () { apply.click(); });
182
182
  row.appendChild(cancel); row.appendChild(apply); card.appendChild(row);
@@ -205,7 +205,7 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
205
205
  annotations.splice(Math.min(index, annotations.length), 0, annotation);
206
206
  markDirty();
207
207
  clearUndo();
208
- renderPlan();
208
+ renderAll();
209
209
  setStatus("Comment restored.", "saved");
210
210
  });
211
211
  controls.appendChild(button);
@@ -217,7 +217,7 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
217
217
  if (!annotation) return;
218
218
  annotations.splice(index, 1);
219
219
  markDirty();
220
- renderPlan();
220
+ renderAll();
221
221
  offerUndo(index, annotation);
222
222
  setStatus("Comment deleted.");
223
223
  }
@@ -237,6 +237,16 @@ export const WEB_COMMENTS_JS = String.raw` function selectionIn(wrap) {
237
237
  var label = el("span", annotation.author ? "bf-comment__label bf-comment__label--author" : "bf-comment__label");
238
238
  label.textContent = annotation.author ? "@" + annotation.author : "Review";
239
239
  head.appendChild(label);
240
+ if (annotation.moved) {
241
+ // A refinement carried this note off the text it was written on: the placement is
242
+ // this build's best guess, and saying so is the difference between the reviewer's
243
+ // anchor and ours.
244
+ var moved = el("span", "bf-comment__moved");
245
+ moved.textContent = "moved";
246
+ moved.title =
247
+ "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
+ head.appendChild(moved);
249
+ }
240
250
  head.appendChild(el("span", "bf-comment__spacer"));
241
251
  if (isMine(annotation)) {
242
252
  var actions = el("div", "bf-comment__actions");
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The review page's composer lifecycle: closing it, reopening the one a reload interrupted,
3
+ * and opening it on the block the keyboard is on.
4
+ *
5
+ * The sixth capability out of the review client's template literal, and the first that
6
+ * needs a bundle rather than a list of arguments: closing touches the page, the status
7
+ * line, the sentence that names a half-written comment, and the draft store — so those
8
+ * arrive as one `ComposerDeps` the client builds where the state already exists. What
9
+ * `openComposer` does with the block stays in `web-comments.ts`, which owns the form.
10
+ */
11
+
12
+ import type { ClientNode } from "../../lib/web/client-runtime.ts";
13
+ import { blockAtLine, blockElements, type IslandBlock, setActive } from "./web-blocks.ts";
14
+
15
+ /** What opening a composer takes: the block, its island entry, a quote, and any draft. */
16
+ export type OpenComposer = (wrap: ClientNode, block: IslandBlock, quote: string, initial?: string) => void;
17
+
18
+ /** What the lifecycle reads of the page. Built where the state lives, once per call. */
19
+ export interface ComposerDeps {
20
+ planEl: ClientNode | null;
21
+ statusEl: ClientNode | null;
22
+ /** The sentence that names a half-written comment, so a stale one can be cleared. */
23
+ refusal: string;
24
+ setStatus(text: string, kind?: string): void;
25
+ saveDraft(): void;
26
+ open: OpenComposer;
27
+ }
28
+
29
+ /**
30
+ * Close the composer, and let the stored draft follow it.
31
+ *
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
+ */
35
+ export function closeComposer(deps: ComposerDeps): void {
36
+ const open = deps.planEl ? deps.planEl.querySelector(".bf-composer") : null;
37
+ if (open) open.remove();
38
+ const composing = deps.planEl ? deps.planEl.querySelectorAll(".bf-block--composing") : [];
39
+ for (let i = 0; i < composing.length; i++) composing[i]?.classList.remove("bf-block--composing");
40
+ // The only status a composer leaves behind is the refusal to leave it; once the draft is
41
+ // gone that sentence is stale.
42
+ if (deps.statusEl && deps.statusEl.textContent === deps.refusal) deps.setStatus("");
43
+ if (open) deps.saveDraft();
44
+ }
45
+
46
+ /**
47
+ * Reopen the composer a reload interrupted, once its block has rendered.
48
+ *
49
+ * A review that has settled, been approved, or been closed has no draft to restore: the
50
+ * text belonged to a page that is no longer asking.
51
+ */
52
+ export function reopenComposer(
53
+ deps: ComposerDeps,
54
+ typing: { anchor: number; quote?: string; text?: string } | undefined,
55
+ settled: { saved: boolean; approved: boolean; closed: boolean },
56
+ blocks: readonly IslandBlock[],
57
+ ): void {
58
+ if (!typing || settled.saved || settled.approved || settled.closed) return;
59
+ const target = blockAtLine(deps.planEl, blocks, typing.anchor);
60
+ if (target) deps.open(target.wrap, target.block, typing.quote || "", typing.text);
61
+ }
62
+
63
+ /**
64
+ * Open the composer on the block the keyboard is on, starting one if none is active.
65
+ *
66
+ * The index is returned so the client keeps its one `activeIndex`: opening a composer is a
67
+ * navigation, and the block the reader lands on is the block they are commenting on.
68
+ */
69
+ export function commentOnActive(deps: ComposerDeps, activeIndex: number, blocks: readonly IslandBlock[]): number {
70
+ if (blockElements(deps.planEl).length === 0) return activeIndex;
71
+ const index = activeIndex < 0 ? (setActive(deps.planEl, 0) ?? activeIndex) : activeIndex;
72
+ const wrap = blockElements(deps.planEl)[Math.max(0, index)];
73
+ if (!wrap) return index;
74
+ const target = blockAtLine(deps.planEl, blocks, Number(wrap.getAttribute("data-line")));
75
+ if (target) deps.open(target.wrap, target.block, "");
76
+ return index;
77
+ }
78
+
79
+ /** The composer lifecycle as source, for the client bundle. */
80
+ export const WEB_COMPOSER_JS = [closeComposer, reopenComposer, commentOnActive].map(String).join("\n\n");
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The review page's draft guard: a composer holding text the page has not committed.
3
+ *
4
+ * The fifth capability out of the review client's template literal. `draftTextarea` is
5
+ * pure — hand it the plan element and it answers the open composer's textarea, or nothing.
6
+ * `guardDraft` is the rule built on it: the ways off the page refuse while a draft exists,
7
+ * say why in the status line, and leave the cursor where the text is, so the reader is not
8
+ * told about a draft they then have to hunt for.
9
+ */
10
+
11
+ import type { ClientNode } from "../../lib/web/client-runtime.ts";
12
+
13
+ /** The open composer's textarea, when it holds anything a reader would lose. */
14
+ export function draftTextarea(planEl: ClientNode | null): { value: string; focus(): void } | null {
15
+ const open = planEl ? planEl.querySelector(".bf-composer") : null;
16
+ const area = open ? (open.querySelector("textarea") as { value: string; focus(): void } | null) : null;
17
+ return area && area.value.trim() ? area : null;
18
+ }
19
+
20
+ /**
21
+ * Whether an action must stop because a comment is half-written, and say so if it must.
22
+ *
23
+ * The refusal is the status line's own sentence rather than an alert: every way off the
24
+ * page runs this, and a modal on each of them would be four dialogs for one fact.
25
+ */
26
+ export function guardDraft(
27
+ planEl: ClientNode | null,
28
+ statusEl: ClientNode | null,
29
+ refusal: string,
30
+ paint: (node: ClientNode, text: string, kind?: string) => void,
31
+ ): boolean {
32
+ const area = draftTextarea(planEl);
33
+ if (!area) return false;
34
+ if (statusEl) paint(statusEl, refusal, "error");
35
+ area.focus();
36
+ return true;
37
+ }
38
+
39
+ /** The draft rules as source, for the client bundle. */
40
+ export const WEB_DRAFT_JS = [draftTextarea, guardDraft].map(String).join("\n\n");
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The review page's keyboard, and the one piece of pointer state a key acts on.
3
+ *
4
+ * The ninth capability out of the review client's template literal — and the last one, by
5
+ * decision: what is left in the bundle after this is wiring (which listener to attach, the
6
+ * frame stream, the boot order), and a rule about which key does what does not belong in a
7
+ * list of `addEventListener` calls.
8
+ *
9
+ * Two rules live here:
10
+ *
11
+ * - **Esc peels one layer.** The shortcut sheet owns its own Esc (the shared dialog runtime
12
+ * captures it), so this peels the composer first and closes the review second, which is
13
+ * the order a reader pressing it twice expects.
14
+ * - **The selection a reader makes is the quote the next comment carries.** The rail button
15
+ * collapses the selection when it is pressed, so the text is remembered on `mouseup` and
16
+ * read again at click time; a block with a composer open is not a selection to quote.
17
+ */
18
+
19
+ import type { ClientNode } from "../../lib/web/client-runtime.ts";
20
+
21
+ /** What a key may do: one callback per action the page owns. */
22
+ export interface KeyDeps {
23
+ /** Whether a composer is open, and how to close it. */
24
+ composerOpen(): boolean;
25
+ closeComposer(): void;
26
+ /** Close the review without saving: the owner's, and a no-op for a reviewer. */
27
+ cancel(): void;
28
+ /** The shortcut sheet: whether it is open (it then owns the keyboard) and its toggle. */
29
+ sheetOpen(): boolean;
30
+ toggleSheet(): void;
31
+ /** Walk the blocks by one, in the direction the key names. */
32
+ moveActive(delta: number): void;
33
+ /** Comment on the block the keyboard is on. */
34
+ commentOnActive(): void;
35
+ /** Approve, through its confirmation; and the two writes `s` can stand for. */
36
+ askApprove(): void;
37
+ save(): void;
38
+ postComments(): void;
39
+ /** Whether a control exists for the key to stand for. */
40
+ hasSaveControl(): boolean;
41
+ hasApproveControl(): boolean;
42
+ /** Whether the key landed in a field, where every key is text. */
43
+ typing(target: unknown): boolean;
44
+ }
45
+
46
+ /** The keys a reader can press, as far as this page is concerned. */
47
+ export interface KeyEvent {
48
+ key: string;
49
+ metaKey?: boolean;
50
+ ctrlKey?: boolean;
51
+ altKey?: boolean;
52
+ target?: unknown;
53
+ preventDefault(): void;
54
+ }
55
+
56
+ /**
57
+ * Handle one keydown.
58
+ *
59
+ * 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.
61
+ */
62
+ export function handleKey(event: KeyEvent, deps: KeyDeps): void {
63
+ if (event.key === "Escape") {
64
+ // The sheet owns its own Esc; this peels the composer, then the review.
65
+ if (deps.composerOpen()) {
66
+ deps.closeComposer();
67
+ return;
68
+ }
69
+ deps.cancel();
70
+ return;
71
+ }
72
+ if (deps.typing(event.target)) return;
73
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
74
+ if (event.key === "?") {
75
+ event.preventDefault();
76
+ deps.toggleSheet();
77
+ return;
78
+ }
79
+ // While the shortcut sheet is open it owns the keyboard.
80
+ if (deps.sheetOpen()) return;
81
+ if (event.key === "j") {
82
+ event.preventDefault();
83
+ deps.moveActive(1);
84
+ return;
85
+ }
86
+ if (event.key === "k") {
87
+ event.preventDefault();
88
+ deps.moveActive(-1);
89
+ return;
90
+ }
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
+ }
107
+
108
+ /** What remembering a selection reads, and where it puts it. */
109
+ export interface QuoteDeps {
110
+ planEl: ClientNode | null;
111
+ /** A settled review takes no more comments, so it remembers no selection. */
112
+ settled(): boolean;
113
+ /** The text a reader selected inside one block. */
114
+ selectionIn(wrap: ClientNode): string;
115
+ /** Remember it, or forget the last one when the release selected nothing. */
116
+ setPending(quote: { wrap: ClientNode; quote: string } | null): void;
117
+ }
118
+
119
+ /**
120
+ * Remember the text a reader selected, so the rail button can still quote it.
121
+ *
122
+ * A release outside a block, or inside a block whose composer is open, clears what was
123
+ * remembered: the next click must not quote text from somewhere the reader has left.
124
+ */
125
+ export function rememberSelection(target: unknown, deps: QuoteDeps): void {
126
+ if (!deps.planEl || deps.settled()) return;
127
+ const node = target as { closest?: (selector: string) => ClientNode | null } | null;
128
+ const wrap = node && typeof node.closest === "function" ? node.closest(".bf-block") : null;
129
+ const quote = wrap && !wrap.querySelector(".bf-composer") ? deps.selectionIn(wrap) : "";
130
+ deps.setPending(wrap && quote ? { wrap, quote } : null);
131
+ }
132
+
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");
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The review page's outline: the section list in the sidebar, and the one rule that says
3
+ * which section a source line belongs to.
4
+ *
5
+ * Written as ordinary TypeScript and exported as source — `WEB_OUTLINE_JS` — because the
6
+ * review client is a template literal no compiler sees. The two functions are given what
7
+ * they read rather than closing over the bundle's variables, which is what makes them
8
+ * testable here: `renderOutline` returns the per-line counters it built instead of
9
+ * assigning the client's `outlineItems`, and `sectionFor` takes the outline it searches.
10
+ *
11
+ * `el` is the client runtime's builder, declared here because this source is spliced into
12
+ * the same bundle it lives in: the declaration emits nothing, so the body calls the one
13
+ * builder. The nodes are reached through a structural type because this project compiles
14
+ * without a DOM lib; the annotations are erased before the source is spliced.
15
+ */
16
+
17
+ import type { ClientNode } from "../../lib/web/client-runtime.ts";
18
+
19
+ /** One entry of the page's `outline` island: a heading, its line, and whether it moved. */
20
+ export interface OutlineEntry {
21
+ id: string;
22
+ level: number;
23
+ line: number;
24
+ text: string;
25
+ changed?: boolean;
26
+ }
27
+
28
+ /** What the browser's `IntersectionObserver` answers for a section watcher. */
29
+ export interface SectionWatcher {
30
+ observe(target: ClientNode): void;
31
+ disconnect(): void;
32
+ }
33
+
34
+ /** What `el` has to answer for an outline. Spliced beside it; declared for the checker. */
35
+ type Build = (tag: string, className?: string, text?: string | null) => ClientNode;
36
+ declare const el: Build;
37
+
38
+ /**
39
+ * The section a source line belongs to: the last heading at or before it.
40
+ *
41
+ * `null` when the line sits above the first heading, which is how a preamble renders
42
+ * without a section of its own.
43
+ */
44
+ export function sectionFor(entries: readonly OutlineEntry[], line: number): OutlineEntry | null {
45
+ let found: OutlineEntry | null = null;
46
+ for (const item of entries) if (item.line <= line) found = item;
47
+ return found;
48
+ }
49
+
50
+ /**
51
+ * Draw the section list and return the counter each line owns.
52
+ *
53
+ * The caller keeps the returned map because the counters are updated later, when the
54
+ * comments are counted per section; returning it is what lets this function be a pure
55
+ * build with no handle on the client's state.
56
+ */
57
+ export function renderOutline(list: ClientNode | null, entries: readonly OutlineEntry[]): Record<number, ClientNode> {
58
+ const counters: Record<number, ClientNode> = {};
59
+ if (!list) return counters;
60
+ list.replaceChildren();
61
+ for (const item of entries) {
62
+ const li = el("li", "lvl-" + item.level);
63
+ const link = el("a");
64
+ link.href = "#" + item.id;
65
+ link.setAttribute("data-id", item.id);
66
+ const label = el("span", "review-nav__link");
67
+ label.textContent = item.text;
68
+ link.appendChild(label);
69
+ if (item.changed) {
70
+ const dot = el("span", "review-changed");
71
+ dot.setAttribute("role", "img");
72
+ dot.setAttribute("aria-label", "Changed since your last review");
73
+ dot.title = "Changed since your last review";
74
+ link.appendChild(dot);
75
+ }
76
+ const count = el("span", "review-nav__count");
77
+ count.hidden = true;
78
+ link.appendChild(count);
79
+ li.appendChild(link);
80
+ list.appendChild(li);
81
+ counters[item.line] = count;
82
+ }
83
+ return counters;
84
+ }
85
+
86
+ /**
87
+ * Watch the blocks and mark the section the reader is in, replacing any previous watcher.
88
+ *
89
+ * The observer is returned rather than stored, so the client owns the one handle it has to
90
+ * disconnect and this function owns the rule: the last block still in the document the
91
+ * observer was built over wins, and a page with no headings needs no watcher at all.
92
+ */
93
+ export function observeSections(
94
+ planEl: ClientNode | null,
95
+ list: ClientNode | null,
96
+ previous: SectionWatcher | null,
97
+ ): SectionWatcher | null {
98
+ if (previous) previous.disconnect();
99
+ if (!planEl || !list) return null;
100
+ const Observer = (
101
+ globalThis as unknown as {
102
+ IntersectionObserver?: new (
103
+ callback: (entries: ArrayLike<{ isIntersecting: boolean; target: { id: string } }>) => void,
104
+ options: Record<string, unknown>,
105
+ ) => SectionWatcher;
106
+ }
107
+ ).IntersectionObserver;
108
+ if (!Observer) return null;
109
+ const links: Record<string, ClientNode> = {};
110
+ const anchors = list.querySelectorAll("a");
111
+ for (let i = 0; i < anchors.length; i++) {
112
+ const link = anchors[i];
113
+ if (link) links[link.getAttribute("data-id") ?? ""] = link;
114
+ }
115
+ const headings = planEl.querySelectorAll(".bf-block[id]");
116
+ if (headings.length === 0) return null;
117
+ const watcher = new Observer(
118
+ (entries) => {
119
+ for (let i = 0; i < entries.length; i++) {
120
+ const entry = entries[i];
121
+ if (!entry || !entry.isIntersecting) continue;
122
+ const all = list.querySelectorAll("a");
123
+ for (let j = 0; j < all.length; j++) all[j]?.removeAttribute("aria-current");
124
+ const link = links[entry.target.id];
125
+ if (link) link.setAttribute("aria-current", "true");
126
+ }
127
+ },
128
+ { rootMargin: "-88px 0px -68% 0px", threshold: 0 },
129
+ );
130
+ for (let i = 0; i < headings.length; i++) {
131
+ const heading = headings[i];
132
+ if (heading) watcher.observe(heading);
133
+ }
134
+ return watcher;
135
+ }
136
+
137
+ /** One review comment, as the facts need it: the line it is anchored to. */
138
+ export interface AnchoredComment {
139
+ anchor: number;
140
+ }
141
+
142
+ /** What a plan's step list says: the numbers the server counted. */
143
+ export interface StepProgress {
144
+ done: number;
145
+ total: number;
146
+ }
147
+
148
+ /**
149
+ * Paint the review facts: the plan's step progress, the comment total, and one count per
150
+ * section in the outline.
151
+ *
152
+ * The counters are the ones `renderOutline` returned, so the two functions agree by
153
+ * construction; a section a comment cannot be placed in is skipped rather than counted
154
+ * against the first heading, which is what "not attached to a current block" means.
155
+ */
156
+ export function renderFacts(
157
+ steps: StepProgress,
158
+ comments: readonly AnchoredComment[],
159
+ counters: Record<number, ClientNode>,
160
+ entries: readonly OutlineEntry[],
161
+ ): void {
162
+ const doc = (
163
+ globalThis as unknown as {
164
+ document: { getElementById(id: string): ClientNode | null };
165
+ }
166
+ ).document;
167
+ // A plan with no Steps section has no progress to show; 0/0 is noise.
168
+ const fact = doc.getElementById("step-fact");
169
+ if (fact) fact.hidden = steps.total === 0;
170
+ const fill = doc.getElementById("step-fill");
171
+ if (fill) fill.style.width = (steps.total > 0 ? Math.round((steps.done / steps.total) * 100) : 0) + "%";
172
+ const count = doc.getElementById("step-count");
173
+ if (count) count.textContent = steps.done + "/" + steps.total;
174
+ const total = doc.getElementById("comment-total");
175
+ if (total) total.textContent = String(comments.length);
176
+ for (const line of Object.keys(counters)) {
177
+ const node = counters[Number(line)];
178
+ if (node) node.hidden = true;
179
+ }
180
+ for (const comment of comments) {
181
+ const section = sectionFor(entries, comment.anchor);
182
+ if (!section) continue;
183
+ const node = counters[section.line];
184
+ if (!node) continue;
185
+ const value = node.hidden ? 0 : Number(node.textContent);
186
+ node.textContent = String(value + 1);
187
+ node.hidden = false;
188
+ }
189
+ }
190
+
191
+ /** The section list, the facts beside it, and the watcher that marks where the reader is. */
192
+ export const WEB_OUTLINE_JS = [sectionFor, renderOutline, observeSections, renderFacts].map(String).join("\n\n");
@@ -19,6 +19,7 @@ import {
19
19
  type KeyHint,
20
20
  keysSheet,
21
21
  SIDEBAR_COLLAPSE_MAX,
22
+ tabTitle,
22
23
  themeToggle,
23
24
  WEB_BASE_CSS,
24
25
  WEB_COMPONENTS_CSS,
@@ -179,6 +180,8 @@ ${WEB_COMPONENTS_CSS}
179
180
  .bf-comment__label{font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--warn);text-transform:uppercase;letter-spacing:.06em}
180
181
  /* A reviewer's name is a name, not a label: keep its own capitalisation. */
181
182
  .bf-comment__label--author{text-transform:none;letter-spacing:0}
183
+ /* A note a refinement carried off its own block: muted, because the complaint is the guess, not the text. */
184
+ .bf-comment__moved{font:var(--fw-medium) var(--fs-xs) var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.06em;border:1px solid var(--border);border-radius:var(--radius-sm);padding:0 6px}
182
185
  .bf-comment__spacer{flex:1 1 auto}
183
186
  .bf-comment__actions{display:flex;gap:4px}
184
187
  .bf-comment__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
@@ -341,7 +344,7 @@ ${reviewHints(data.role, data.canApprove)}
341
344
  return `<!doctype html>
342
345
  <html lang="en">
343
346
  <head>
344
- ${docHead({ title: "Plan review", css: REVIEW_CSS })}
347
+ ${docHead({ title: tabTitle("plan review", data.title), css: REVIEW_CSS })}
345
348
  </head>
346
349
  <body class="bf-app">
347
350
  <a class="skip" href="#plan">Skip to the plan</a>