@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
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
13
+ import { RenderCache } from "../../lib/tui/render-cache.ts";
13
14
  import { goalHeader, goalLine } from "./format.ts";
14
15
  import type { AchievedStyle, Goal } from "./types.ts";
15
16
 
@@ -47,6 +48,7 @@ export class GoalWidget implements Component {
47
48
  export class GoalHeader implements Component {
48
49
  private goal: Goal;
49
50
  private theme: Theme;
51
+ private readonly cache = new RenderCache();
50
52
 
51
53
  constructor(goal: Goal, theme: Theme) {
52
54
  this.goal = goal;
@@ -57,13 +59,21 @@ export class GoalHeader implements Component {
57
59
  update(goal: Goal, theme: Theme): void {
58
60
  this.goal = goal;
59
61
  this.theme = theme;
62
+ this.cache.invalidate();
60
63
  }
61
64
 
62
- invalidate(): void {}
65
+ invalidate(): void {
66
+ this.cache.invalidate();
67
+ }
63
68
 
64
69
  render(width: number): string[] {
70
+ const w = Math.max(1, width);
71
+ return this.cache.lines(w, undefined, () => this.build(w));
72
+ }
73
+
74
+ private build(w: number): string[] {
65
75
  // The leading blank line separates the header from the call line, matching
66
76
  // the built-ins' header/output gap.
67
- return ["", truncateToWidth(goalHeader(this.goal, this.theme), Math.max(1, width), "…")];
77
+ return ["", truncateToWidth(goalHeader(this.goal, this.theme), w, "…")];
68
78
  }
69
79
  }
@@ -11,7 +11,15 @@
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { errorResult, textResult } from "../../lib/tool-result.ts";
13
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
- import { callLine, detailsOrLine, elapsedSuffix, rejectionLine, syncElapsedTimer } from "../../lib/tui/tool.ts";
14
+ import {
15
+ callLine,
16
+ detailsOrLine,
17
+ elapsedSuffix,
18
+ isTailResult,
19
+ rejectionLine,
20
+ syncElapsedTimer,
21
+ TailResult,
22
+ } from "../../lib/tui/tool.ts";
15
23
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
24
  import { paint } from "../../lib/tui/tokens.ts";
17
25
  import {
@@ -26,8 +34,8 @@ import {
26
34
  import type { JobsRuntime } from "./runtime.ts";
27
35
  import { JobParams, normalizeCall, type JobArgs, type JobCall } from "./schema.ts";
28
36
  import { toRecord } from "./store.ts";
29
- import { JobResult, type JobResultInput } from "./tui.ts";
30
- import type { JobDetails } from "./types.ts";
37
+ import { jobTail, type JobResultInput } from "./tui.ts";
38
+ import type { JobDetails, JobRecord } from "./types.ts";
31
39
 
32
40
  export const TOOL_NAME = "job";
33
41
 
@@ -224,8 +232,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
224
232
  }
225
233
  const shown = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
226
234
  const input: JobResultInput = { kind: "logs", lines: shown, earlier: all.length - shown.length };
227
- const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
228
- view.setInput(input, theme);
235
+ const previous = context.lastComponent;
236
+ const tail = jobTail(input);
237
+ const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
238
+ view.setInput(tail, theme);
229
239
  return view;
230
240
  }
231
241
 
@@ -242,8 +252,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
242
252
  more: ordered.length - shown.length,
243
253
  sessionId: details.sessionId,
244
254
  };
245
- const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
246
- view.setInput(input, theme);
255
+ const previous = context.lastComponent;
256
+ const tail = jobTail(input);
257
+ const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
258
+ view.setInput(tail, theme);
247
259
  return view;
248
260
  }
249
261
 
@@ -15,13 +15,12 @@
15
15
  */
16
16
 
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
- import type { Component } from "@earendil-works/pi-tui";
19
18
  import { formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts";
20
19
  import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
21
20
  import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
22
21
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
23
22
  import { statusRow, type StatusRow } from "../../lib/tui/rows.ts";
24
- import { TailResult, type TailResultInput } from "../../lib/tui/tool.ts";
23
+ import type { TailResultInput } from "../../lib/tui/tool.ts";
25
24
  import { paint } from "../../lib/tui/tokens.ts";
26
25
  import {
27
26
  compareJobs,
@@ -62,7 +61,7 @@ export type JobResultInput =
62
61
  | { kind: "logs"; lines: string[]; earlier: number };
63
62
 
64
63
  /** Map the job vocabulary onto the shared transcript tail block. */
65
- function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
64
+ export function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
66
65
  if (input.kind === "logs") {
67
66
  return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: "lines" };
68
67
  }
@@ -77,31 +76,6 @@ function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
77
76
  };
78
77
  }
79
78
 
80
- /**
81
- * Transcript result block for the `job` tool: the shared tail block over the
82
- * `/jobs`-style row rail or the themed log tail. Reused across renders.
83
- */
84
- export class JobResult implements Component {
85
- private readonly view: TailResult<JobRecord>;
86
-
87
- constructor(input: JobResultInput, theme: Theme) {
88
- this.view = new TailResult(jobTail(input), theme);
89
- }
90
-
91
- /** Update in place so the transcript can reuse this component across renders. */
92
- setInput(input: JobResultInput, theme: Theme): void {
93
- this.view.setInput(jobTail(input), theme);
94
- }
95
-
96
- invalidate(): void {
97
- this.view.invalidate();
98
- }
99
-
100
- render(width: number): string[] {
101
- return this.view.render(width);
102
- }
103
- }
104
-
105
79
  export interface JobListCallbacks {
106
80
  /** This session's id: a record with another id belongs to a peer session. */
107
81
  sessionId(): string;
@@ -19,7 +19,7 @@ import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
19
19
  import { runBrowserReview } from "./review.ts";
20
20
  import type { PlanRuntime } from "./runtime.ts";
21
21
  import type { PlanSummary } from "./plans.ts";
22
- import { createPlanWebReview, type PlanWebReview } from "./web.ts";
22
+ import { createPlanWebReview, NO_SERVER_DEPS, type PlanWebReview } from "./web.ts";
23
23
 
24
24
  const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
25
25
  const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
@@ -219,7 +219,7 @@ async function openPlanIndex(
219
219
  export function registerCommands(
220
220
  pi: ExtensionAPI,
221
221
  runtime: PlanRuntime,
222
- web: PlanWebReview = createPlanWebReview(runtime.plans),
222
+ web: PlanWebReview = createPlanWebReview(runtime.plans, NO_SERVER_DEPS),
223
223
  ): void {
224
224
  pi.registerCommand("plan", {
225
225
  description:
@@ -43,6 +43,13 @@ export interface PlanAnnotation {
43
43
  * guess, and the page says so instead of presenting a guess as her own anchor.
44
44
  */
45
45
  moved?: true;
46
+ /**
47
+ * Set when the note is settled: its writer, or the owner, decided it needs no action, or
48
+ * the revision answered it. The comment stays in the file — it is the record of the
49
+ * question — and a review sends it flagged, so the model reads what is closed without
50
+ * treating it as new.
51
+ */
52
+ resolved?: true;
46
53
  }
47
54
 
48
55
  /** Most annotations one save may carry. */
@@ -59,10 +66,10 @@ export type AuthorClaim = { name: string } | { status: number; message: string }
59
66
 
60
67
  /**
61
68
  * A stored feedback line, capturing whether a refinement moved it, its author, and its
62
- * comment. The flag is inside the marker tag rather than the comment, so the text a
69
+ * comment. The flags are inside the marker tag rather than the comment, so the text a
63
70
  * reviewer wrote stays exactly what she typed.
64
71
  */
65
- const MARKER_LINE = /^\s*>\s*\[!REVIEW(\s+moved)?\]\s?(.*)$/;
72
+ const MARKER_LINE = /^\s*>\s*\[!REVIEW((?:\s+(?:moved|resolved))*)\]\s?(.*)$/;
66
73
  /** The author prefix inside a marker body (`@alice: note`). */
67
74
  const AUTHOR_PREFIX = /^@([^:]{1,40}):\s?(.*)$/;
68
75
 
@@ -133,6 +140,8 @@ interface StoredMarker {
133
140
  author: string;
134
141
  /** Whether a refinement carried this comment off the text it was written on. */
135
142
  moved: boolean;
143
+ /** Whether the note is settled: it stays in the file, and is no longer an open question. */
144
+ resolved: boolean;
136
145
  }
137
146
 
138
147
  /** The plan body without markers, plus the markers that were removed from it. */
@@ -151,17 +160,28 @@ function scanMarkers(plan: string): { clean: string; markers: StoredMarker[] } {
151
160
  const authored = AUTHOR_PREFIX.exec(body);
152
161
  const author = authored ? normalizeAuthor(authored[1]) : "";
153
162
  const comment = (authored ? authored[2] : body).trim();
154
- if (comment) markers.push({ at: clean.length - 1, comment, author, moved: match[1] !== undefined });
163
+ // The tag carries a set of flags, not one keyword: a note can be carried off its text
164
+ // and settled in the same write, and an older build reading it still finds the comment.
165
+ const flags = new Set((match[1] ?? "").trim().split(/\s+/).filter(Boolean));
166
+ if (comment) {
167
+ markers.push({
168
+ at: clean.length - 1,
169
+ comment,
170
+ author,
171
+ moved: flags.has("moved"),
172
+ resolved: flags.has("resolved"),
173
+ });
174
+ }
155
175
  });
156
176
  return { clean: clean.join("\n"), markers };
157
177
  }
158
178
 
159
179
  /** One marker line in its stored form. */
160
- function markerLine(author: string, comment: string, moved = false): string {
180
+ function markerLine(author: string, comment: string, moved = false, resolved = false): string {
161
181
  const body = sanitizeLine(comment);
162
- // The flag rides inside the tag, where it cannot be confused with the comment: after
163
- // the closing bracket, a comment that begins "moved …" would read as the flag.
164
- const tag = moved ? "[!REVIEW moved]" : REVIEW_MARKER;
182
+ // The flags ride inside the tag, in a fixed order, where they cannot be confused with the
183
+ // comment: after the closing bracket, a comment that begins "moved …" would read as one.
184
+ const tag = moved || resolved ? `[!REVIEW${moved ? " moved" : ""}${resolved ? " resolved" : ""}]` : REVIEW_MARKER;
165
185
  return author ? `> ${tag} @${author}: ${body}` : `> ${tag} ${body}`;
166
186
  }
167
187
 
@@ -236,6 +256,7 @@ export function readFeedback(plan: string): FeedbackRead {
236
256
  };
237
257
  if (marker.author) annotation.author = marker.author;
238
258
  if (marker.moved) annotation.moved = true;
259
+ if (marker.resolved) annotation.resolved = true;
239
260
  return annotation;
240
261
  });
241
262
  return { body: clean, blocks, annotations };
@@ -249,15 +270,21 @@ export function parseFeedback(plan: string): PlanAnnotation[] {
249
270
  /** Sanitized, non-empty comments with the anchor they land on. */
250
271
  function storableComments(
251
272
  annotations: readonly PlanAnnotation[],
252
- ): Array<{ anchor: number; comment: string; moved: boolean }> {
253
- const items: Array<{ anchor: number; comment: string; moved: boolean }> = [];
273
+ ): Array<{ anchor: number; comment: string; moved: boolean; resolved: boolean }> {
274
+ const items: Array<{ anchor: number; comment: string; moved: boolean; resolved: boolean }> = [];
254
275
  for (const annotation of annotations) {
255
276
  const comment = sanitizeLine(annotation.comment);
256
277
  if (comment.length === 0 || comment.length > MAX_COMMENT_LENGTH) continue;
257
278
  // Editing a moved note's text does not put it back on its old block, so the fact
258
279
  // that a refinement placed it where it sits survives the save: the page posts back
259
- // the annotation it was handed, flag included, and this keeps it.
260
- items.push({ anchor: annotation.anchor, comment, moved: annotation.moved === true });
280
+ // the annotation it was handed, flags included, and this keeps them. Settling is the
281
+ // writer's own decision of the same kind.
282
+ items.push({
283
+ anchor: annotation.anchor,
284
+ comment,
285
+ moved: annotation.moved === true,
286
+ resolved: annotation.resolved === true,
287
+ });
261
288
  }
262
289
  return items;
263
290
  }
@@ -272,13 +299,16 @@ export function formatFeedback(plan: string, annotations: PlanAnnotation[], opti
272
299
  const { clean, markers } = scanMarkers(plan);
273
300
  const kept = markers
274
301
  .filter((marker) => marker.author !== author)
275
- .map((marker) => ({ at: marker.at, line: markerLine(marker.author, marker.comment, marker.moved) }));
302
+ .map((marker) => ({
303
+ at: marker.at,
304
+ line: markerLine(marker.author, marker.comment, marker.moved, marker.resolved),
305
+ }));
276
306
  const blocks = planBlocks(clean);
277
307
  const added = storableComments(annotations).map((item) => ({
278
308
  // A marker beside a block, never after a blank line: an anchor that names
279
309
  // blank space is written where the page can read it back as a thread.
280
310
  at: contentBlockAt(blocks, item.anchor)?.end ?? -1,
281
- line: markerLine(author, item.comment, item.moved),
311
+ line: markerLine(author, item.comment, item.moved, item.resolved),
282
312
  }));
283
313
  return compose(clean, [...kept, ...added]);
284
314
  }
@@ -452,7 +482,9 @@ export function carryFeedback(previous: string, next: string): string {
452
482
  // The flag means "this note is no longer on the text it was written about", so it
453
483
  // is decided by the carry in hand: a block whose text survived proves the anchor,
454
484
  // and anything after that is a guess the page has to admit to.
455
- const keep = (moved: boolean): string => markerLine(marker.author, marker.comment, moved);
485
+ // Settling is the note's own history, not the carry's decision: a moved note that was
486
+ // resolved stays settled, and one whose text survived stays as it was.
487
+ const keep = (moved: boolean): string => markerLine(marker.author, marker.comment, moved, marker.resolved);
456
488
  if (block === undefined) return { at: tail, line: keep(true) };
457
489
  if (block.kind !== "blank") {
458
490
  const match = queues.get(blockKey(block))?.shift();
@@ -503,13 +535,17 @@ export function feedbackSummary(annotations: PlanAnnotation[]): string {
503
535
  // the plan owner chose, so it is normalized like every other author.
504
536
  author: normalizeAuthor(annotation.author),
505
537
  comment: sanitizeLine(annotation.comment),
538
+ resolved: annotation.resolved === true,
506
539
  }))
507
540
  .filter((item) => item.comment.length > 0);
508
541
  return items
509
542
  .map((item, index) => {
510
543
  const who = item.author ? `@${item.author}: ` : "";
511
544
  const where = item.quote ? `On "${item.quote}": ` : "";
512
- return `${index + 1}. ${who}${where}${item.comment}`;
545
+ // A settled note is still sent — it is the record of the question — but saying so
546
+ // is what stops the model from answering it a second time.
547
+ const settled = item.resolved ? " (resolved)" : "";
548
+ return `${index + 1}. ${who}${where}${item.comment}${settled}`;
513
549
  })
514
550
  .join("\n");
515
551
  }
@@ -531,12 +567,22 @@ export function coerceAnnotations(value: unknown): PlanAnnotation[] | undefined
531
567
  if (typeof record.comment !== "string") return undefined;
532
568
  if (record.quote !== undefined && typeof record.quote !== "string") return undefined;
533
569
  if (record.author !== undefined && typeof record.author !== "string") return undefined;
570
+ if (record.resolved !== undefined && typeof record.resolved !== "boolean") return undefined;
534
571
  const comment = sanitizeLine(record.comment);
535
572
  if (comment.length > MAX_COMMENT_LENGTH) return undefined;
536
573
  if (comment.length === 0) continue;
537
574
  const quote = typeof record.quote === "string" ? sanitizeLine(record.quote).slice(0, MAX_QUOTE_LENGTH) : undefined;
538
575
  const author = normalizeAuthor(record.author);
539
- annotations.push({ anchor, comment, ...(quote ? { quote } : {}), ...(author ? { author } : {}) });
576
+ // A note a refinement carried onto another block keeps saying so when the page posts
577
+ // it back, and a settled note keeps saying that too: only this body knows either.
578
+ annotations.push({
579
+ anchor,
580
+ comment,
581
+ ...(quote ? { quote } : {}),
582
+ ...(author ? { author } : {}),
583
+ ...(record.moved === true ? { moved: true } : {}),
584
+ ...(record.resolved === true ? { resolved: true } : {}),
585
+ });
540
586
  }
541
587
  return annotations;
542
588
  }
@@ -21,6 +21,7 @@ import { Key } from "@earendil-works/pi-tui";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { hasPathInput } from "./path-guard.ts";
23
23
  import { isExtensionEnabled } from "../../lib/env.ts";
24
+ import { requestMount } from "../../lib/http/index.ts";
24
25
  import { sanitizeLine } from "../../lib/format.ts";
25
26
  import { lastGoal, isPursuedGoal, type PublishedGoal } from "../../lib/goal-state.ts";
26
27
  import { READ_ONLY_SUMMARY, PLAN_ENTER_REQUEST, type PlanEnterRequest } from "../../lib/plan-entry.ts";
@@ -91,7 +92,12 @@ export default function planMode(pi: ExtensionAPI): void {
91
92
  if (!isExtensionEnabled("plan")) return;
92
93
  const policy = createPlanPolicy(pi);
93
94
  const runtime = createPlanRuntime(pi, policy, { entryPath: extensionEntryPath() });
94
- const web = createPlanWebReview(runtime.plans);
95
+ /** The session this review serves as; its prefix lands on that session's one server. */
96
+ let sessionId = "";
97
+ const web = createPlanWebReview(runtime.plans, {
98
+ mount: (spec) => requestMount(pi, spec),
99
+ session: () => sessionId,
100
+ });
95
101
 
96
102
  registerTools(pi, runtime, web);
97
103
  registerCommands(pi, runtime, web);
@@ -17,6 +17,7 @@ import { createHash } from "node:crypto";
17
17
  import { stripControlChars } from "../../lib/format.ts";
18
18
  import { displayFeedback, stripFeedback } from "./feedback.ts";
19
19
  import { extractSections, sectionKeys, type PlanSection } from "./sections.ts";
20
+ import { planBlocks } from "./blocks.ts";
20
21
 
21
22
  /** Spaces a tab expands to; the terminal's own tab stops are not width-modelled. */
22
23
  const TAB_WIDTH = 4;
@@ -27,6 +28,14 @@ export interface SectionDigest {
27
28
  title: string;
28
29
  /** SHA-256 of the section's normalized text, heading included. */
29
30
  digest: string;
31
+ /**
32
+ * Short digests of this section's content blocks, in order.
33
+ *
34
+ * Absent in a baseline a build before block marks wrote, which is why every reader
35
+ * treats it as optional: a section with no block digests marks nothing, and the page
36
+ * says it cannot compare at that depth rather than reading as "nothing changed".
37
+ */
38
+ blocks?: string[];
30
39
  }
31
40
 
32
41
  /**
@@ -60,6 +69,17 @@ function digestOf(text: string): string {
60
69
  return createHash("sha256").update(text.trim()).digest("hex");
61
70
  }
62
71
 
72
+ /**
73
+ * A short, stable digest of one block's lines.
74
+ *
75
+ * Short because it is persisted once per content block in a session entry: eight hex
76
+ * characters tell two blocks apart for the length of one review, which is all a change
77
+ * mark needs, and keep a long plan's baseline small enough to sit in the branch.
78
+ */
79
+ export function blockDigest(lines: readonly string[]): string {
80
+ return digestOf(lines.join("\n")).slice(0, 8);
81
+ }
82
+
63
83
  /**
64
84
  * A short, stable id for one plan text. The review page keys this tab's unsaved
65
85
  * draft on it, so a draft is restored for the plan it was written against and
@@ -74,9 +94,103 @@ export function sectionDigests(content: string): SectionDigest[] {
74
94
  return extractSections(reviewText(content)).map((section) => ({
75
95
  title: section.title,
76
96
  digest: digestOf(section.text),
97
+ // Blank blocks are separators, not content: a plan that gained a blank line has not
98
+ // changed, and a digest per separator would say it had.
99
+ blocks: planBlocks(section.text)
100
+ .filter((block) => block.kind !== "blank")
101
+ .map((block) => blockDigest(block.lines)),
77
102
  }));
78
103
  }
79
104
 
105
+ /** Where the blocks changed, in the document the page is about to render. */
106
+ export interface BlockChangeMarks {
107
+ /** Absolute 0-based start lines of the blocks that are new or edited. */
108
+ starts: number[];
109
+ /** Content blocks each section no longer holds, keyed by the section's heading line. */
110
+ removed: Record<number, number>;
111
+ /**
112
+ * Whether the baseline carried block digests at all.
113
+ *
114
+ * False for a baseline a build before block marks wrote: section marks still mean
115
+ * something, block marks cannot, and the page says which instead of drawing nothing.
116
+ */
117
+ compared: boolean;
118
+ }
119
+
120
+ /**
121
+ * Where the blocks changed since the baseline, as the rendered document numbers them.
122
+ *
123
+ * The pages marks what it draws, so every line here is a line of `current`: a section's
124
+ * blocks are numbered from its own heading, which is why the section's line is added
125
+ * back. Sections are paired the way {@link changedSections} pairs them — by occurrence,
126
+ * so a second `## Notes` is never compared with the first — and a section the baseline
127
+ * never held is all new: every block in it marks, because adding or renaming a heading is
128
+ * a refinement rather than a reason to refuse the comparison. Only a baseline section
129
+ * written before block digests is beyond comparing at that depth, and it alone clears
130
+ * `compared`.
131
+ */
132
+ export function blockChangeMarks(baseline: readonly SectionDigest[] | undefined, current: string): BlockChangeMarks {
133
+ const sections = extractSections(reviewText(current));
134
+ if (baseline === undefined) return { starts: [], removed: {}, compared: false };
135
+ const before = new Map<string, SectionDigest>();
136
+ sectionKeys(baseline).forEach((key, at) => {
137
+ const section = baseline[at];
138
+ if (section !== undefined) before.set(key, section);
139
+ });
140
+ const starts: number[] = [];
141
+ const removed: Record<number, number> = {};
142
+ let compared = true;
143
+ sectionKeys(sections).forEach((key, at) => {
144
+ const section = sections[at];
145
+ if (section === undefined) return;
146
+ const prior = before.get(key);
147
+ if (prior !== undefined && prior.blocks === undefined) {
148
+ compared = false;
149
+ return;
150
+ }
151
+ const mine = planBlocks(section.text).filter((block) => block.kind !== "blank");
152
+ // An unheld section pairs with no blocks, so every block in it is new.
153
+ const priorBlocks = new Set(prior?.blocks ?? []);
154
+ for (const block of mine) {
155
+ if (!priorBlocks.has(blockDigest(block.lines))) starts.push(section.line + block.start);
156
+ }
157
+ const shrunk = Math.max(0, (prior?.blocks?.length ?? 0) - mine.length);
158
+ if (shrunk > 0) removed[section.line] = shrunk;
159
+ });
160
+ return { starts, removed, compared };
161
+ }
162
+
163
+ /** What one section's blocks look like against the baseline's. */
164
+ export interface BlockMarks {
165
+ /** Indexes, among the section's content blocks, of the blocks that are new or edited. */
166
+ changed: number[];
167
+ /** How many content blocks the section no longer holds: the part it shrank by. */
168
+ removed: number;
169
+ }
170
+
171
+ /**
172
+ * Mark the blocks a section gained or edited since the baseline.
173
+ *
174
+ * Membership rather than position: a block is changed when its digest is not among the
175
+ * baseline's, so an inserted step marks itself and leaves every block after it alone —
176
+ * the index-by-index pairing a diff would use would mark the whole rest of the section.
177
+ * A block that left cannot be drawn in place, so the section reports how much shorter it
178
+ * has become instead — the shrinkage, not the set difference, so an edited block reads
179
+ * as one change rather than as one removal beside one addition.
180
+ *
181
+ * An absent or blockless baseline marks nothing: a build before block marks wrote no
182
+ * block digests, and claiming every block changed would be a worse lie than saying the
183
+ * comparison cannot be made at that depth.
184
+ */
185
+ export function changedBlocks(prior: readonly string[] | undefined, current: readonly string[]): BlockMarks {
186
+ if (prior === undefined) return { changed: [], removed: 0 };
187
+ const before = new Set(prior);
188
+ return {
189
+ changed: current.flatMap((digest, index) => (before.has(digest) ? [] : [index])),
190
+ removed: Math.max(0, prior.length - current.length),
191
+ };
192
+ }
193
+
80
194
  /** Whether two baselines describe the same content, section for section. */
81
195
  export function sameSections(a: readonly SectionDigest[], b: readonly SectionDigest[]): boolean {
82
196
  if (a.length !== b.length) return false;
@@ -100,9 +100,19 @@ function readSections(value: unknown): SectionDigest[] | undefined {
100
100
  if (!Array.isArray(value)) return undefined;
101
101
  const sections: SectionDigest[] = [];
102
102
  for (const raw of value) {
103
- const section = raw as { title?: unknown; digest?: unknown } | null | undefined;
103
+ const section = raw as { title?: unknown; digest?: unknown; blocks?: unknown } | null | undefined;
104
104
  if (typeof section?.title !== "string" || typeof section.digest !== "string") return undefined;
105
- sections.push({ title: section.title, digest: section.digest });
105
+ // Branch data is untrusted, so the block digests are kept only when they are a list
106
+ // of strings: a baseline written before block marks simply has none, and one that
107
+ // holds something else is read at section depth rather than repaired.
108
+ const blocks = Array.isArray(section.blocks)
109
+ ? section.blocks.filter((entry): entry is string => typeof entry === "string")
110
+ : undefined;
111
+ sections.push(
112
+ blocks === undefined
113
+ ? { title: section.title, digest: section.digest }
114
+ : { title: section.title, digest: section.digest, blocks },
115
+ );
106
116
  }
107
117
  return sections;
108
118
  }
@@ -28,12 +28,12 @@ import { registerEnterTool } from "./tools/enter.ts";
28
28
  import { registerExitTool } from "./tools/exit.ts";
29
29
  import { registerSetPlanStepsTool } from "./tools/steps.ts";
30
30
  import { registerWriteTool } from "./tools/write.ts";
31
- import { createPlanWebReview, type PlanWebReview } from "./web.ts";
31
+ import { createPlanWebReview, NO_SERVER_DEPS, type PlanWebReview } from "./web.ts";
32
32
 
33
33
  export function registerTools(
34
34
  pi: ExtensionAPI,
35
35
  runtime: PlanRuntime,
36
- web: PlanWebReview = createPlanWebReview(runtime.plans),
36
+ web: PlanWebReview = createPlanWebReview(runtime.plans, NO_SERVER_DEPS),
37
37
  ): void {
38
38
  registerEnterTool(pi, runtime);
39
39
  registerCancelTool(pi, runtime);
@@ -59,13 +59,20 @@ export function setActive(planEl: ClientNode | null, index: number): number | nu
59
59
  * The index one step from `activeIndex`, or `null` when there is nothing to walk.
60
60
  *
61
61
  * With nothing active yet, `j` starts at the top and `k` at the bottom, which is what a
62
- * reader pressing either key on a fresh page expects.
62
+ * reader pressing either key on a fresh page expects. A view can hide blocks, and a
63
+ * hidden block is not a place the reader can be sent: the step lands on the next block
64
+ * they can actually see, or nowhere when the filter left none.
63
65
  */
64
66
  export function moveActive(planEl: ClientNode | null, activeIndex: number, delta: number): number | null {
65
67
  const blocks = blockElements(planEl);
66
68
  if (blocks.length === 0) return null;
67
- const next = activeIndex < 0 ? (delta < 0 ? blocks.length - 1 : 0) : activeIndex + delta;
68
- return setActive(planEl, next);
69
+ const step = delta < 0 ? -1 : 1;
70
+ const first =
71
+ activeIndex < 0 ? (step < 0 ? blocks.length - 1 : 0) : Math.min(Math.max(activeIndex + step, 0), blocks.length - 1);
72
+ for (let index = first; index >= 0 && index < blocks.length; index += step) {
73
+ if (blocks[index]?.hidden !== true) return setActive(planEl, index);
74
+ }
75
+ return null;
69
76
  }
70
77
 
71
78
  /**
@@ -85,5 +92,69 @@ export function blockAtLine(
85
92
  return wrap && block ? { wrap, block } : null;
86
93
  }
87
94
 
88
- /** The block rules as source, for the client bundle. */
89
- export const WEB_BLOCKS_JS = [blockElements, setActive, moveActive, blockAtLine].map(String).join("\n\n");
95
+ /** What the reader asked the plan to show. */
96
+ export type ReviewView = "all" | "changed" | "comments";
97
+
98
+ /** How much of the plan a view kept, so the page can say what it is hiding. */
99
+ export interface ViewCounts {
100
+ shown: number;
101
+ hidden: number;
102
+ }
103
+
104
+ /**
105
+ * Keep the blocks a view asks for, and hide the rest.
106
+ *
107
+ * A view is a filter over what is drawn, not a second document: `All` restores every
108
+ * block, so the reader is one click from the whole plan, and the page's status line says
109
+ * how many were hidden so a filtered document never reads as a short one. Headings stay
110
+ * in every view — a paragraph without its section is a sentence without a subject — and a
111
+ * view with nothing to show keeps the document whole rather than blanking it.
112
+ */
113
+ /**
114
+ * Keep the blocks a view asks for, and hide the rest.
115
+ *
116
+ * A view is a filter over what is drawn, not a second document: `All` restores every
117
+ * block, so the reader is one click from the whole plan, and the page's status line says
118
+ * how many were hidden so a filtered document never reads as a short one. Headings stay
119
+ * in every view — a paragraph without its section is a sentence without a subject — and a
120
+ * view with nothing to show keeps the document whole rather than blanking it. A comment
121
+ * thread is hidden with the block it hangs under: a note with no text it refers to reads
122
+ * as a comment on whatever happens to be above it.
123
+ */
124
+ export function applyView(planEl: ClientNode | null, view: ReviewView): ViewCounts {
125
+ if (!planEl) return { shown: 0, hidden: 0 };
126
+ const blocks = blockElements(planEl);
127
+ const keep = (block: ClientNode): boolean =>
128
+ view === "all" ||
129
+ block.getAttribute("data-kind") === "heading" ||
130
+ (view === "changed"
131
+ ? block.getAttribute("data-changed") === "1"
132
+ : (block.className || "").split(" ").indexOf("bf-block--commented") >= 0);
133
+ // A filter that matches nothing but the headings is not a view of the plan: showing an
134
+ // empty page is worse than showing the whole document beside the count.
135
+ const matched = blocks.filter((block) => block.getAttribute("data-kind") !== "heading" && keep(block)).length;
136
+ const whole = view !== "all" && blocks.length > 0 && matched === 0;
137
+ const visible: Record<string, boolean> = {};
138
+ for (const block of blocks) {
139
+ block.hidden = !whole && !keep(block);
140
+ if (!block.hidden) visible[block.getAttribute("data-line") ?? ""] = true;
141
+ }
142
+ const threads = planEl.querySelectorAll(".bf-comments");
143
+ for (let i = 0; i < threads.length; i++) {
144
+ const thread = threads[i];
145
+ if (!thread) continue;
146
+ const line = thread.getAttribute("data-line");
147
+ if (line !== null) thread.hidden = visible[line] !== true;
148
+ }
149
+ const shown = whole ? blocks.length : blocks.filter(keep).length;
150
+ return { shown, hidden: blocks.length - shown };
151
+ }
152
+
153
+ /**
154
+ * The block rules as source, for the client bundle.
155
+ *
156
+ * `applyView` travels with them because the page's view control calls it on every render:
157
+ * a name the bundle does not carry is a `ReferenceError` at the first repaint, which
158
+ * aborts the boot before the composer and the progress bar are wired.
159
+ */
160
+ export const WEB_BLOCKS_JS = [blockElements, setActive, moveActive, blockAtLine, applyView].map(String).join("\n\n");
@@ -76,6 +76,10 @@ export const WEB_CHROME_JS = String.raw` function setStatus(text, kind) {
76
76
  if (saveButton) saveButton.disabled = true;
77
77
  if (postButton) postButton.disabled = true;
78
78
  if (cancelButton) cancelButton.disabled = true;
79
+ // The rail's comment controls go with them: a settled review takes no new comment, and
80
+ // the frame renderer is the one rule that decides that, so this asks for a repaint
81
+ // rather than removing the buttons itself.
82
+ renderAll();
79
83
  }
80
84
 
81
85
  // A conflict means the file moved under the page; only a reload can re-anchor.