@gavin-hu/my-pi-agent 0.19.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -0,0 +1,212 @@
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
+ /** Content blocks this section no longer holds, when the baseline can say so. */
27
+ removed?: number;
28
+ }
29
+
30
+ /** What the browser's `IntersectionObserver` answers for a section watcher. */
31
+ export interface SectionWatcher {
32
+ observe(target: ClientNode): void;
33
+ disconnect(): void;
34
+ }
35
+
36
+ /** What `el` has to answer for an outline. Spliced beside it; declared for the checker. */
37
+ type Build = (tag: string, className?: string, text?: string | null) => ClientNode;
38
+ declare const el: Build;
39
+
40
+ /**
41
+ * The section a source line belongs to: the last heading at or before it.
42
+ *
43
+ * `null` when the line sits above the first heading, which is how a preamble renders
44
+ * without a section of its own.
45
+ */
46
+ export function sectionFor(entries: readonly OutlineEntry[], line: number): OutlineEntry | null {
47
+ let found: OutlineEntry | null = null;
48
+ for (const item of entries) if (item.line <= line) found = item;
49
+ return found;
50
+ }
51
+
52
+ /**
53
+ * Draw the section list and return the counter each line owns.
54
+ *
55
+ * The caller keeps the returned map because the counters are updated later, when the
56
+ * comments are counted per section; returning it is what lets this function be a pure
57
+ * build with no handle on the client's state.
58
+ */
59
+ export function renderOutline(list: ClientNode | null, entries: readonly OutlineEntry[]): Record<number, ClientNode> {
60
+ const counters: Record<number, ClientNode> = {};
61
+ if (!list) return counters;
62
+ list.replaceChildren();
63
+ for (const item of entries) {
64
+ const li = el("li", "lvl-" + item.level);
65
+ const link = el("a");
66
+ link.href = "#" + item.id;
67
+ link.setAttribute("data-id", item.id);
68
+ const label = el("span", "review-nav__link");
69
+ label.textContent = item.text;
70
+ link.appendChild(label);
71
+ if (item.changed) {
72
+ const dot = el("span", "review-changed");
73
+ dot.setAttribute("role", "img");
74
+ dot.setAttribute("aria-label", "Changed since your last review");
75
+ dot.title = "Changed since your last review";
76
+ link.appendChild(dot);
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
+ }
86
+ const count = el("span", "review-nav__count");
87
+ count.hidden = true;
88
+ link.appendChild(count);
89
+ li.appendChild(link);
90
+ list.appendChild(li);
91
+ counters[item.line] = count;
92
+ }
93
+ return counters;
94
+ }
95
+
96
+ /**
97
+ * Watch the blocks and mark the section the reader is in, replacing any previous watcher.
98
+ *
99
+ * The observer is returned rather than stored, so the client owns the one handle it has to
100
+ * disconnect and this function owns the rule: the last block still in the document the
101
+ * observer was built over wins, and a page with no headings needs no watcher at all.
102
+ */
103
+ export function observeSections(
104
+ planEl: ClientNode | null,
105
+ list: ClientNode | null,
106
+ previous: SectionWatcher | null,
107
+ ): SectionWatcher | null {
108
+ if (previous) previous.disconnect();
109
+ if (!planEl || !list) return null;
110
+ const Observer = (
111
+ globalThis as unknown as {
112
+ IntersectionObserver?: new (
113
+ callback: (entries: ArrayLike<{ isIntersecting: boolean; target: { id: string } }>) => void,
114
+ options: Record<string, unknown>,
115
+ ) => SectionWatcher;
116
+ }
117
+ ).IntersectionObserver;
118
+ if (!Observer) return null;
119
+ const links: Record<string, ClientNode> = {};
120
+ const anchors = list.querySelectorAll("a");
121
+ for (let i = 0; i < anchors.length; i++) {
122
+ const link = anchors[i];
123
+ if (link) links[link.getAttribute("data-id") ?? ""] = link;
124
+ }
125
+ const headings = planEl.querySelectorAll(".bf-block[id]");
126
+ if (headings.length === 0) return null;
127
+ const watcher = new Observer(
128
+ (entries) => {
129
+ for (let i = 0; i < entries.length; i++) {
130
+ const entry = entries[i];
131
+ if (!entry || !entry.isIntersecting) continue;
132
+ const all = list.querySelectorAll("a");
133
+ for (let j = 0; j < all.length; j++) all[j]?.removeAttribute("aria-current");
134
+ const link = links[entry.target.id];
135
+ if (link) link.setAttribute("aria-current", "true");
136
+ }
137
+ },
138
+ { rootMargin: "-88px 0px -68% 0px", threshold: 0 },
139
+ );
140
+ for (let i = 0; i < headings.length; i++) {
141
+ const heading = headings[i];
142
+ if (heading) watcher.observe(heading);
143
+ }
144
+ return watcher;
145
+ }
146
+
147
+ /** One review comment, as the facts need it: the line it is anchored to. */
148
+ export interface AnchoredComment {
149
+ anchor: number;
150
+ /** A settled note counts in the section's thread but not among the open ones. */
151
+ resolved?: true;
152
+ }
153
+
154
+ /** What a plan's step list says: the numbers the server counted. */
155
+ export interface StepProgress {
156
+ done: number;
157
+ total: number;
158
+ }
159
+
160
+ /**
161
+ * Paint the review facts: the plan's step progress, the comment total, and one count per
162
+ * section in the outline.
163
+ *
164
+ * The counters are the ones `renderOutline` returned, so the two functions agree by
165
+ * construction; a section a comment cannot be placed in is skipped rather than counted
166
+ * against the first heading, which is what "not attached to a current block" means.
167
+ */
168
+ export function renderFacts(
169
+ steps: StepProgress,
170
+ comments: readonly AnchoredComment[],
171
+ counters: Record<number, ClientNode>,
172
+ entries: readonly OutlineEntry[],
173
+ ): void {
174
+ const doc = (
175
+ globalThis as unknown as {
176
+ document: { getElementById(id: string): ClientNode | null };
177
+ }
178
+ ).document;
179
+ // A plan with no Steps section has no progress to show; 0/0 is noise.
180
+ const fact = doc.getElementById("step-fact");
181
+ if (fact) fact.hidden = steps.total === 0;
182
+ const fill = doc.getElementById("step-fill");
183
+ if (fill) fill.style.width = (steps.total > 0 ? Math.round((steps.done / steps.total) * 100) : 0) + "%";
184
+ const count = doc.getElementById("step-count");
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";
188
+ const total = doc.getElementById("comment-total");
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
+ }
196
+ for (const line of Object.keys(counters)) {
197
+ const node = counters[Number(line)];
198
+ if (node) node.hidden = true;
199
+ }
200
+ for (const comment of comments) {
201
+ const section = sectionFor(entries, comment.anchor);
202
+ if (!section) continue;
203
+ const node = counters[section.line];
204
+ if (!node) continue;
205
+ const value = node.hidden ? 0 : Number(node.textContent);
206
+ node.textContent = String(value + 1);
207
+ node.hidden = false;
208
+ }
209
+ }
210
+
211
+ /** The section list, the facts beside it, and the watcher that marks where the reader is. */
212
+ export const WEB_OUTLINE_JS = [sectionFor, renderOutline, observeSections, renderFacts].map(String).join("\n\n");
@@ -14,80 +14,27 @@
14
14
  */
15
15
 
16
16
  import {
17
+ baseAttribute,
17
18
  brandLockup,
18
19
  docHead,
19
20
  type KeyHint,
20
21
  keysSheet,
21
22
  SIDEBAR_COLLAPSE_MAX,
23
+ tabTitle,
22
24
  themeToggle,
23
25
  WEB_BASE_CSS,
24
26
  WEB_COMPONENTS_CSS,
25
27
  WEB_LAYOUT_CSS,
26
28
  WEB_THEME_CSS,
27
29
  } from "../../lib/web/index.ts";
28
- import type { PlanBlock } from "./blocks.ts";
29
- import type { PlanAnnotation } from "./feedback.ts";
30
+ import { keyRows, type KeySurface } from "./web-keys.ts";
30
31
  import type { ReviewRole } from "./types.ts";
31
- import type { ShareState } from "./web-share.ts";
32
+ import type { ReviewPageData } from "./web-payload.ts";
32
33
 
33
- /** A heading the outline can jump to. */
34
- export interface ReviewOutlineItem {
35
- /** DOM id the client gives the heading. */
36
- id: string;
37
- /** 0-based source line. */
38
- line: number;
39
- /** Heading level, 1-6. */
40
- level: number;
41
- /** Heading text. */
42
- text: string;
43
- /** True when this section changed since the previous review of the plan. */
44
- changed?: boolean;
45
- }
46
-
47
- /** Everything the browser needs to render one plan review. */
48
- export interface ReviewPageData {
49
- /** Plan title: the document's H1, or the file-name slug when it has none. */
50
- title: string;
51
- /** Source-line blocks of the feedback-stripped plan. */
52
- blocks: PlanBlock[];
53
- /** Feedback already stored in the file. */
54
- annotations: PlanAnnotation[];
55
- /** Section outline, in document order. */
56
- outline: ReviewOutlineItem[];
57
- /**
58
- * The plan's step progress, from the same `extractPlanSteps` the approval and
59
- * `set_plan_steps` use: the `Steps` section when there is one, so the page
60
- * agrees with what approval seeds rather than counting every checkbox.
61
- */
62
- steps: { done: number; total: number };
63
- /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
64
- code: Record<string, string[]>;
65
- /**
66
- * The caps the server enforces, so the page does not retype them: comment
67
- * count, comment length, and quote length, from `feedback.ts`.
68
- */
69
- limits: { comments: number; comment: number; quote: number };
70
- /** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
71
- canApprove: boolean;
72
- /** Who is looking: `owner` may approve, `reviewer` may only comment. */
73
- role: ReviewRole;
74
- /**
75
- * The name a reviewer's link posts under, once it has been bound. Absent for the
76
- * owner and for a reviewer that has not written yet, which is what tells the page
77
- * whether its name control is still a choice.
78
- */
79
- author?: string;
80
- /** A running share, so a reload re-renders the link the owner already minted. */
81
- share?: ShareState;
82
- /** Per-session secret the client echoes on every request. */
83
- token: string;
84
- /**
85
- * A short digest of the plan text the page rendered. It keys the unsaved draft a
86
- * tab keeps for itself, so a reload restores the comments this writer has not
87
- * saved yet, and a plan that changed underneath drops them instead.
88
- */
89
- rev: string;
90
- }
34
+ // The island's two types live with the code that builds it (`web-payload.ts`); they are
35
+ // re-exported here because a page's data and the page's markup are read from the same
36
+ // imports in every caller, and two declarations of one island is how its halves drift.
37
+ export type { ReviewOutlineItem, ReviewPageData } from "./web-payload.ts";
91
38
 
92
39
  /** Page styles: the shared base + theme + shell + components, then the review. */
93
40
  export const REVIEW_CSS = `${WEB_BASE_CSS}
@@ -116,16 +63,24 @@ ${WEB_COMPONENTS_CSS}
116
63
  .review-status{flex:0 0 auto;max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
117
64
  .review-status:empty{display:none}
118
65
  .review-controls{display:inline-flex;align-items:center;gap:var(--space-2);flex:0 0 auto}
66
+ .review-caption{margin:0;color:var(--muted);font-size:var(--fs-xs);line-height:var(--lh-snug)}
119
67
  .review-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
120
68
  .review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
121
69
  /* Review panel: the plan's review facts, the section outline, then the keys. */
122
70
  .bf-app__body--single{grid-template-columns:minmax(0,1fr)}
123
71
  .review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
124
72
  .review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
125
- .review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
73
+ .review-fact{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
74
+ /* The label is one word or two, never a column: squeezed by the value beside it, it broke
75
+ "Plan steps" over two lines. It holds its line, and the value wraps below instead. */
76
+ .review-fact>span:first-child{white-space:nowrap}
126
77
  .review-fact .bf-progress{color:var(--text)}
127
78
  .review-fact .bf-progress__track{width:96px}
128
79
  .review-fact__value{color:var(--text);font-family:var(--font-mono);font-variant-numeric:tabular-nums}
80
+ .review-view{display:flex;gap:2px;padding:2px;border:1px solid var(--border);border-radius:var(--radius-md)}
81
+ .review-view .bf-btn{flex:1 1 auto;justify-content:center;padding:3px 8px;font-size:var(--fs-xs);border:0;background:none}
82
+ .review-view .bf-btn[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent);font-weight:var(--fw-semibold)}
83
+ .review-view .bf-btn:disabled{opacity:.5;cursor:default}
129
84
  .review-nav{min-width:0}
130
85
  .review-nav__title{margin:0;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
131
86
  .review-nav ul{list-style:none;margin:0;padding:0}
@@ -148,6 +103,10 @@ ${WEB_COMPONENTS_CSS}
148
103
  .bf-block__add:hover{border-color:var(--accent);color:var(--accent)}
149
104
  .bf-block:hover .bf-block__add,.bf-block:focus-within .bf-block__add,.bf-block--active .bf-block__add,.bf-block--commented .bf-block__add{opacity:1}
150
105
  .bf-block__count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;border-radius:var(--radius-pill);background:var(--accent-soft);color:var(--accent);font:var(--fw-semibold) var(--fs-xs)/1 var(--font-mono)}
106
+ /* A block the last review did not hold: the one mark that says "read this line again". The
107
+ rule sits at the text edge, inside the gutter the rail already reserves, so it cannot
108
+ reflow the measure or collide with a comment badge. */
109
+ .bf-block[data-changed]{box-shadow:inset 2px 0 0 var(--accent)}
151
110
  .bf-block:hover,.bf-block--active{background:var(--surface)}
152
111
  .bf-block--active{box-shadow:inset 0 0 0 1px var(--border)}
153
112
  .bf-block h1,.bf-block h2,.bf-block h3,.bf-block h4,.bf-block h5,.bf-block h6{margin:24px 0 8px;line-height:var(--lh-tight)}
@@ -179,7 +138,12 @@ ${WEB_COMPONENTS_CSS}
179
138
  .bf-comment__label{font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--warn);text-transform:uppercase;letter-spacing:.06em}
180
139
  /* A reviewer's name is a name, not a label: keep its own capitalisation. */
181
140
  .bf-comment__label--author{text-transform:none;letter-spacing:0}
141
+ /* A note a refinement carried off its own block: muted, because the complaint is the guess, not the text. */
142
+ .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
143
  .bf-comment__spacer{flex:1 1 auto}
144
+ /* A settled note keeps its quote and loses its body: the record stays, the eye moves on. */
145
+ .bf-comment--resolved{border-left-color:var(--success);opacity:.72}
146
+ .bf-comment__resolved{font:var(--fw-medium) var(--fs-xs) var(--font-mono);color:var(--success);text-transform:uppercase;letter-spacing:.06em}
183
147
  .bf-comment__actions{display:flex;gap:4px}
184
148
  .bf-comment__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
185
149
  .bf-comment__quote{margin-bottom:6px;padding-left:8px;border-left:2px solid var(--border);color:var(--muted);font-size:var(--fs-sm);overflow-wrap:anywhere}
@@ -222,7 +186,12 @@ ${WEB_COMPONENTS_CSS}
222
186
  @media print{
223
187
  .bf-review{padding:0}
224
188
  .bf-review>.bf-block{max-width:none;padding:0 0 6px}
225
- .bf-block__rail,.bf-composer,.bf-block__add,.review-progress{display:none}
189
+ .bf-block__rail,.bf-composer,.bf-block__add,.review-progress,.review-view{display:none}
190
+ /* A printed review is the whole plan, never the view the reader had on screen: the
191
+ base layer's hidden rule is !important, so the un-hide has to be too, and a thread
192
+ the filter hid on screen goes back under its block. */
193
+ .bf-block[hidden]{display:block!important}
194
+ .bf-comments[hidden]{display:flex!important}
226
195
  .bf-block:hover,.bf-block--active{background:none;box-shadow:none}
227
196
  .bf-comments{padding-left:0}
228
197
  .bf-comment{break-inside:avoid}
@@ -234,28 +203,19 @@ ${WEB_COMPONENTS_CSS}
234
203
  * not have to grow every time a binding is added.
235
204
  */
236
205
  function reviewHints(role: ReviewRole, canApprove: boolean): string {
237
- const approve = canApprove ? `, <kbd class="bf-kbd">a</kbd> to approve` : "";
238
- const save = role === "owner" ? "save" : "post";
239
- return `<p class="review-hints">Press <kbd class="bf-kbd">j</kbd>/<kbd class="bf-kbd">k</kbd> to move, <kbd class="bf-kbd">c</kbd> to comment${approve}, <kbd class="bf-kbd">s</kbd> to ${save}, <kbd class="bf-kbd">?</kbd> for all shortcuts.</p>`;
206
+ const surface: KeySurface = { role, canApprove, hasCommentWalk: true, hasViewCycle: true };
207
+ const parts = keyRows(surface)
208
+ .filter((row) => row.hint !== undefined)
209
+ .map(
210
+ (row) => `${row.keys.map((key) => `<kbd class="bf-kbd">${key}</kbd>`).join("/")} ${row.hint?.(surface) ?? ""}`,
211
+ );
212
+ return `<p class="review-hints">Press ${parts.join(", ")}.</p>`;
240
213
  }
241
214
 
242
- /** The shortcut sheet's rows: the review's own keys, led by approve when offered. */
215
+ /** The shortcut sheet's rows: the same table the handler dispatches through. */
243
216
  function reviewKeys(role: ReviewRole, canApprove: boolean): KeyHint[] {
244
- const rows: KeyHint[] = [];
245
- if (canApprove) rows.push({ keys: "a", label: "Approve and run the plan" });
246
- rows.push(
247
- { keys: ["j", "k"], label: "Next and previous block" },
248
- { keys: "c", label: "Comment on the current block" },
249
- { keys: "Enter", label: "Add the comment you are writing" },
250
- role === "owner"
251
- ? { keys: "s", label: "Save feedback and return to Pi" }
252
- : { keys: "s", label: "Post your comments to the plan" },
253
- role === "owner"
254
- ? { keys: "Esc", label: "Close the composer, the sheet, or the review" }
255
- : { keys: "Esc", label: "Close the composer or this sheet" },
256
- { keys: "?", label: "Toggle this sheet" },
257
- );
258
- return rows;
217
+ const surface: KeySurface = { role, canApprove, hasCommentWalk: true, hasViewCycle: true };
218
+ return keyRows(surface).map((row) => ({ keys: [...row.keys], label: row.label(surface) }));
259
219
  }
260
220
 
261
221
  /**
@@ -308,13 +268,26 @@ function nameDialog(): string {
308
268
  */
309
269
  export function renderReviewPage(data: ReviewPageData): string {
310
270
  const island = JSON.stringify(data).replace(/</g, "\\u003c");
271
+ // One sentence saying what the two decisions do, because the buttons are the same weight
272
+ // and the difference was only ever in the docs. It sits in the panel, not the sticky
273
+ // header: a taller header would hide the section an outline link just jumped to.
274
+ const caption =
275
+ data.role === "owner"
276
+ ? "Approving runs the plan and returns these comments; saving keeps planning."
277
+ : "Your comments are signed with your name and returned to the plan.";
311
278
  const hasOutline = data.outline.length > 0;
312
279
  const bodyClass = hasOutline ? "bf-app__body" : "bf-app__body bf-app__body--single";
313
280
  const outlinePanel = hasOutline
314
281
  ? `<details class="bf-app__sidebar review-outline" open>
315
282
  <summary class="bf-panel__summary">Sections</summary>
316
283
  <div class="bf-panel review-panel">
284
+ <p class="review-caption">${caption}</p>
317
285
  ${reviewFacts()}
286
+ <div class="review-view" role="group" aria-label="What to show" id="review-view">
287
+ <button type="button" class="bf-btn" data-view="all" aria-pressed="true">All</button>
288
+ <button type="button" class="bf-btn" data-view="changed" aria-pressed="false">Changed</button>
289
+ <button type="button" class="bf-btn" data-view="comments" aria-pressed="false">Comments</button>
290
+ </div>
318
291
  <nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
319
292
  ${reviewHints(data.role, data.canApprove)}
320
293
  </div>
@@ -339,9 +312,9 @@ ${reviewHints(data.role, data.canApprove)}
339
312
  ? `<button type="button" class="bf-btn" id="share">Share</button>`
340
313
  : `<button type="button" class="bf-btn" id="reviewer-name" hidden></button>`;
341
314
  return `<!doctype html>
342
- <html lang="en">
315
+ <html lang="en"${baseAttribute(data.base)}>
343
316
  <head>
344
- ${docHead({ title: "Plan review", css: REVIEW_CSS })}
317
+ ${docHead({ title: tabTitle("plan review", data.title), css: REVIEW_CSS, base: data.base })}
345
318
  </head>
346
319
  <body class="bf-app">
347
320
  <a class="skip" href="#plan">Skip to the plan</a>
@@ -364,7 +337,7 @@ ${keysSheet(reviewKeys(data.role, data.canApprove))}
364
337
  ${data.canApprove ? approveDialog() : ""}
365
338
  ${data.role === "owner" ? shareDialog() : nameDialog()}
366
339
  <script type="application/json" id="review-data">${island}</script>
367
- <script src="/client.js" defer></script>
340
+ <script src="${data.base}/client.js" defer></script>
368
341
  </body>
369
342
  </html>
370
343
  `;
@@ -376,7 +349,7 @@ ${data.role === "owner" ? shareDialog() : nameDialog()}
376
349
  */
377
350
  function reviewFacts(): string {
378
351
  return `<div class="review-facts" id="review-facts">
379
- <div class="review-fact" id="step-fact"><span>Steps</span><span class="bf-progress"><span class="bf-progress__track"><span class="bf-progress__fill" id="step-fill"></span></span><span class="review-fact__value" id="step-count">0/0</span></span></div>
352
+ <div class="review-fact" id="step-fact"><span>Plan steps</span><span class="bf-progress"><span class="bf-progress__track"><span class="bf-progress__fill" id="step-fill"></span></span><span class="review-fact__value" id="step-count">0/0</span></span></div>
380
353
  <div class="review-fact"><span>Comments</span><span class="review-fact__value" id="comment-total">0</span></div>
381
354
  </div>`;
382
355
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The review page's island: every fact the browser renders, built from one read of the
3
+ * plan.
4
+ *
5
+ * The page's markup is `web-page.ts` and its lifetime is `web.ts`; this module is the
6
+ * data between them, so a fact the page grows — a per-block change mark, a count, a
7
+ * flag — is added here and in one client module rather than inside the server's start
8
+ * closure. Everything below is pure: the plan text, the baseline, and who is asking come
9
+ * in, the island goes out.
10
+ */
11
+
12
+ import { basename } from "node:path";
13
+ import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
14
+ import { codeBody, fenceInfo, type PlanBlock } from "./blocks.ts";
15
+ import {
16
+ MAX_ANNOTATIONS,
17
+ MAX_COMMENT_LENGTH,
18
+ MAX_QUOTE_LENGTH,
19
+ readFeedback,
20
+ type PlanAnnotation,
21
+ } from "./feedback.ts";
22
+ import { sanitizePlanText } from "./plan-text.ts";
23
+ import { blockChangeMarks, changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
24
+ import { extractPlanSteps } from "./steps.ts";
25
+ import { planTitle } from "./plans.ts";
26
+ import type { ReviewCaller, ReviewRole } from "./types.ts";
27
+ import type { ShareState } from "./web-share.ts";
28
+
29
+ /** A heading the outline can jump to. */
30
+ export interface ReviewOutlineItem {
31
+ /** DOM id the client gives the heading. */
32
+ id: string;
33
+ /** 0-based source line. */
34
+ line: number;
35
+ /** Heading level, 1-6. */
36
+ level: number;
37
+ /** Heading text. */
38
+ text: string;
39
+ /** True when this section changed since the previous review of the plan. */
40
+ changed?: boolean;
41
+ /** Content blocks this section no longer holds, when the baseline can say so. */
42
+ removed?: number;
43
+ }
44
+
45
+ /** Everything the browser needs to render one plan review. */
46
+ export interface ReviewPageData {
47
+ /** The mount prefix the review answers under, so its own script is named from there. */
48
+ base: string;
49
+ /** Plan title: the document's H1, or the file-name slug when it has none. */
50
+ title: string;
51
+ /** Source-line blocks of the feedback-stripped plan. */
52
+ blocks: PlanBlock[];
53
+ /** Absolute start lines of the blocks that are new or edited since the previous review. */
54
+ changed?: number[];
55
+ /** Whether the previous review's baseline carried block digests, so `changed` means something. */
56
+ blockMarks?: boolean;
57
+ /** Feedback already stored in the file. */
58
+ annotations: PlanAnnotation[];
59
+ /** Section outline, in document order. */
60
+ outline: ReviewOutlineItem[];
61
+ /**
62
+ * The plan's step progress, from the same `extractPlanSteps` the approval and
63
+ * `set_plan_steps` use: the `Steps` section when there is one, so the page
64
+ * agrees with what approval seeds rather than counting every checkbox.
65
+ */
66
+ steps: { done: number; total: number };
67
+ /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
68
+ code: Record<string, string[]>;
69
+ /**
70
+ * The caps the server enforces, so the page does not retype them: comment
71
+ * count, comment length, and quote length, from `feedback.ts`.
72
+ */
73
+ limits: { comments: number; comment: number; quote: number };
74
+ /** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
75
+ canApprove: boolean;
76
+ /** Who is looking: `owner` may approve, `reviewer` may only comment. */
77
+ role: ReviewRole;
78
+ /**
79
+ * The name a reviewer's link posts under, once it has been bound. Absent for the
80
+ * owner and for a reviewer that has not written yet, which is what tells the page
81
+ * whether its name control is still a choice.
82
+ */
83
+ author?: string;
84
+ /** A running share, so a reload re-renders the link the owner already minted. */
85
+ share?: ShareState;
86
+ /** Per-session secret the client echoes on every request. */
87
+ token: string;
88
+ /**
89
+ * A short digest of the plan text the page rendered. It keys the unsaved draft a
90
+ * tab keeps for itself, so a reload restores the comments this writer has not
91
+ * saved yet, and a plan that changed underneath drops them instead.
92
+ */
93
+ rev: string;
94
+ }
95
+
96
+ /** What one island build reads. */
97
+ export interface ReviewPayloadInput {
98
+ /** The mount prefix the review answers under, e.g. `/plan/3f9c21aa`. */
99
+ base: string;
100
+ /** The plan's path, for the title's file-name fallback. */
101
+ path: string;
102
+ /** The plan as stored, feedback markers included. */
103
+ content: string;
104
+ /** The section digests the previous review showed, so this page can mark what moved. */
105
+ baseline?: readonly SectionDigest[];
106
+ /** Who is asking; the role decides what the page offers. */
107
+ caller: ReviewCaller;
108
+ /** The session secret the page echoes on every request. */
109
+ token: string;
110
+ /** Whether this review may approve the plan. */
111
+ canApprove: boolean;
112
+ /** The running share, read lazily: sharing can start after the page is first served. */
113
+ share: () => ShareState | undefined;
114
+ }
115
+
116
+ /**
117
+ * Build the island for one page request.
118
+ *
119
+ * The plan is read once, and the blocks, the comments, and the outline all come out of
120
+ * that same parse: an annotation names a block from this read, so the island cannot mark
121
+ * a comment onto a line the page never rendered.
122
+ */
123
+ export function reviewPageData(input: ReviewPayloadInput): ReviewPageData {
124
+ const { caller } = input;
125
+ const role = caller.role;
126
+ // Normalized the way the baseline is, so a block's digest here is the one the baseline
127
+ // wrote: the page and the baseline must read the plan the same way, or every tabbed plan
128
+ // would come back marked as changed. Not stripped, because the comments have to survive
129
+ // for `readFeedback` to parse them.
130
+ const read = readFeedback(sanitizePlanText(input.content));
131
+ const current = read.body;
132
+ const blocks = read.blocks;
133
+ // Per-section change marks, each carrying its own section: the same shape the
134
+ // terminal review renders, so a heading and its mark cannot be paired wrongly.
135
+ const marks = changedSections(input.baseline, current);
136
+ // Which blocks moved, in the document's own numbering: the page draws these, and a
137
+ // section's shrinkage is the half of a diff a document cannot place in the flow.
138
+ const blockMarks = blockChangeMarks(input.baseline, current);
139
+ const outline = marks.flatMap(({ section, changed }) =>
140
+ section.title === ""
141
+ ? []
142
+ : [
143
+ {
144
+ id: `s-${section.line}`,
145
+ line: section.line,
146
+ level: section.level,
147
+ text: section.title,
148
+ ...(changed ? { changed: true } : {}),
149
+ ...(blockMarks.removed[section.line] === undefined ? {} : { removed: blockMarks.removed[section.line] }),
150
+ },
151
+ ],
152
+ );
153
+ // The same step list approval seeds and set_plan_steps indexes, so the page's
154
+ // progress cannot disagree with the todo list.
155
+ const stepList = extractPlanSteps(current);
156
+ // Highlight code blocks once, at the boundary: the client copies only the tok-*
157
+ // spans out of the server-escaped HTML.
158
+ const code: Record<string, string[]> = {};
159
+ for (const block of blocks) {
160
+ if (block.kind !== "code") continue;
161
+ const language = fenceLanguage(fenceInfo(block.lines[0] ?? ""));
162
+ if (language) code[String(block.start)] = highlightLines(codeBody(block), language);
163
+ }
164
+ return {
165
+ base: input.base,
166
+ // The document's own H1 reads better than the file-name slug, and the slug is
167
+ // still the fallback for a plan that starts with something else.
168
+ title: marks.find((mark) => mark.section.level === 1)?.section.title || planTitle(basename(input.path)),
169
+ blocks,
170
+ ...(blockMarks.starts.length === 0 ? {} : { changed: blockMarks.starts }),
171
+ ...(blockMarks.compared ? { blockMarks: true } : {}),
172
+ annotations: read.annotations,
173
+ outline,
174
+ steps: {
175
+ done: stepList.filter((step) => step.status === "completed").length,
176
+ total: stepList.length,
177
+ },
178
+ code,
179
+ limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
180
+ token: input.token,
181
+ role,
182
+ // What the page keys this tab's unsaved draft on: same plan text, same draft.
183
+ rev: planDigest(current),
184
+ ...(caller.name === undefined ? {} : { author: caller.name }),
185
+ canApprove: role === "owner" && input.canApprove,
186
+ share: role === "owner" ? input.share() : undefined,
187
+ };
188
+ }