@gavin-hu/my-pi-agent 0.8.1 → 0.9.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 (98) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +7 -7
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +51 -9
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +385 -19
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +33 -0
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +1 -1
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -33,13 +33,17 @@ import {
33
33
  import { stripControlChars } from "../../lib/format.ts";
34
34
  import {
35
35
  editorTheme,
36
- screenHeader,
37
36
  screenHint,
38
37
  viewportRows,
39
38
  type ScreenHint,
40
39
  type ViewportRowsSource,
41
- } from "../../lib/tui.ts";
42
- import { GLYPHS } from "../../lib/ui.ts";
40
+ } from "../../lib/tui/chrome.ts";
41
+ import { isCloseKey } from "../../lib/tui/cursor.ts";
42
+ import { gaugeBar } from "../../lib/tui/gauge.ts";
43
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
44
+ import { titleRule } from "../../lib/tui/rules.ts";
45
+ import { emptyState, checkbox } from "../../lib/tui/rows.ts";
46
+ import { paint } from "../../lib/tui/tokens.ts";
43
47
  import { planTitle, type StoredPlan } from "./plans.ts";
44
48
  import { extractPlanSections } from "./steps.ts";
45
49
 
@@ -110,14 +114,12 @@ export function sanitizePlanText(text: string): string {
110
114
 
111
115
  /** A task checkbox in a list marker: `- [x] `, `1. [ ] `. */
112
116
  const TASK_MARKER = /^([-*+]|\d+[.)])(\s+)(\[([ xX])\])(\s*)$/;
113
- /** Task-checkbox markers; three columns wide so list wrapping stays aligned. */
114
- const TASK_BOX = { done: `[${GLYPHS.check}]`, pending: "[ ]" } as const;
115
117
 
116
118
  /**
117
- * Wrap the Markdown theme so task checkboxes read at a glance: done steps get a
118
- * `success` check, pending steps a `dim` box. The replacement keeps the marker's
119
- * visible width (`[✓]` and `[ ]` are both three columns), so wrapped list
120
- * continuation lines stay aligned.
119
+ * Wrap the Markdown theme so task checkboxes read at a glance with the shared
120
+ * `◼`/`◻` vocabulary, the same box the bytefly screen and the question dialog
121
+ * draw. The replacement is one column wide instead of the bracket's three, so a
122
+ * wrapped list continuation shifts left by two.
121
123
  */
122
124
  function planMarkdownTheme(theme: Theme, base: MarkdownTheme): MarkdownTheme {
123
125
  return {
@@ -126,27 +128,19 @@ function planMarkdownTheme(theme: Theme, base: MarkdownTheme): MarkdownTheme {
126
128
  const match = TASK_MARKER.exec(text);
127
129
  if (!match) return base.listBullet(text);
128
130
  const [, bullet, gap, , mark, trail] = match;
129
- const box = mark.toLowerCase() === "x" ? theme.fg("success", TASK_BOX.done) : theme.fg("dim", TASK_BOX.pending);
130
- return `${base.listBullet(`${bullet}${gap}`)}${box}${trail}`;
131
+ return `${base.listBullet(`${bullet}${gap}`)}${checkbox(theme, mark.toLowerCase() === "x")}${trail}`;
131
132
  },
132
133
  };
133
134
  }
134
135
 
135
- /** A fixed-width gauge: `fillSegments` (rounded) in `fill`, the rest dim. */
136
- function gauge(theme: Theme, fillSegments: number, fill: "accent" | "success"): string {
137
- const count = Math.min(GAUGE_SEGMENTS, Math.max(0, Math.round(fillSegments)));
138
- return (
139
- theme.fg(fill, GLYPHS.gaugeFull.repeat(count)) + theme.fg("dim", GLYPHS.gaugeEmpty.repeat(GAUGE_SEGMENTS - count))
140
- );
141
- }
142
-
143
136
  /** Scroll position below the body: a gauge, the percent, and the visible range. */
144
137
  function scrollRow(theme: Theme, width: number, offset: number, end: number, total: number, section?: string): string {
145
138
  const maxOffset = Math.max(0, total - (end - offset));
146
139
  const percent = maxOffset > 0 ? Math.round((offset / maxOffset) * 100) : 100;
147
- const head = `${gauge(theme, (percent / 100) * GAUGE_SEGMENTS, "accent")} ${theme.fg("accent", `${percent}%`)}`;
148
- const range = theme.fg("dim", `lines ${offset + 1}–${end} of ${total}`);
149
- const label = section ? theme.fg("muted", `${GLYPHS.plan} ${section}`) : "";
140
+ const bar = gaugeBar(theme, { percent, blocks: GAUGE_SEGMENTS, role: "accent", emptyRole: "faint" });
141
+ const head = `${bar} ${paint(theme, "accent", `${percent}%`)}`;
142
+ const range = paint(theme, "faint", `lines ${offset + 1}–${end} of ${total}`);
143
+ const label = section ? paint(theme, "meta", `${GLYPHS.plan} ${section}`) : "";
150
144
  // Keep the gauge and percent; drop the range first, then the section label, on a
151
145
  // narrow terminal.
152
146
  const candidates = [
@@ -295,7 +289,7 @@ export class PlanViewComponent implements Component {
295
289
 
296
290
  handleInput(data: string): void {
297
291
  if (this.editing) {
298
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
292
+ if (isCloseKey(data)) {
299
293
  this.cancelRefine();
300
294
  return;
301
295
  }
@@ -303,7 +297,7 @@ export class PlanViewComponent implements Component {
303
297
  this.options.requestRender();
304
298
  return;
305
299
  }
306
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
300
+ if (isCloseKey(data)) {
307
301
  this.options.onClose("keep");
308
302
  return;
309
303
  }
@@ -361,19 +355,19 @@ export class PlanViewComponent implements Component {
361
355
  this.offset = Math.min(Math.max(0, this.offset), maxOffset);
362
356
  const sectionLabel = this.currentSection(sections)?.title;
363
357
 
364
- const lines: string[] = [screenHeader(theme, w, truncateToWidth(`Plan Review · ${title}`, Math.max(1, w - 6)))];
358
+ const lines: string[] = [titleRule(theme, w, truncateToWidth(`Plan Review · ${title}`, Math.max(1, w - 6)))];
365
359
  lines.push("");
366
360
 
367
361
  const end = Math.min(body.length, this.offset + visible);
368
362
  for (let i = this.offset; i < end; i++) lines.push(body[i]);
369
- if (body.length === 0) lines.push(truncateToWidth(` ${theme.fg("dim", "This plan is empty.")}`, w));
363
+ if (body.length === 0) lines.push(...emptyState(theme, w, { label: "This plan is empty." }));
370
364
 
371
365
  if (this.editing) {
372
366
  // Keep the scroll position visible while editing: the body above is still
373
367
  // clipped, so the reader needs to know where they are.
374
368
  if (this.maxOffset > 0) lines.push(scrollRow(theme, w, this.offset, end, body.length, sectionLabel));
375
369
  else lines.push("");
376
- lines.push(truncateToWidth(` ${theme.fg("muted", "Refine the plan:")}`, w));
370
+ lines.push(truncateToWidth(` ${paint(theme, "meta", "Refine the plan:")}`, w));
377
371
  for (const line of editorLines) lines.push(truncateToWidth(` ${line}`, w));
378
372
  lines.push("");
379
373
  lines.push(screenHint(theme, w, ["Esc back to plan", { text: "Enter submit", color: "success" }]));
@@ -5,10 +5,10 @@
5
5
  * build step. It renders the plan from the JSON data island using DOM text
6
6
  * nodes only — plan and comment text never becomes markup — renders each block
7
7
  * with its comment threads indented directly beneath it, highlights code by
8
- * copying only `tok-*` spans through `DOMParser`, builds the section outline,
9
- * and saves the annotation list back to the server. An `EventSource` keeps a
10
- * connection open so the server can tell when the tab is gone; the explicit
11
- * buttons are the primary save/cancel path.
8
+ * copying only `tok-*` spans through `DOMParser`, builds the section outline
9
+ * and review facts, and saves the annotation list back to the server. An
10
+ * `EventSource` keeps a connection open so the server can tell when the tab is
11
+ * gone; the explicit buttons are the primary save/cancel path.
12
12
  */
13
13
 
14
14
  import { THEME_TOGGLE_JS } from "../../lib/web/index.ts";
@@ -26,14 +26,22 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
26
26
  var annotations = Array.isArray(data.annotations) ? data.annotations.slice() : [];
27
27
  var saved = false;
28
28
  var closed = false;
29
+ var connected = null;
30
+ var everConnected = false;
31
+ var dirty = false;
29
32
  var sectionObserver = null;
30
33
  var pendingQuote = null;
34
+ var activeIndex = -1;
35
+ var outlineItems = {};
31
36
 
32
37
  var planEl = document.getElementById("plan");
33
38
  var statusEl = document.getElementById("status");
39
+ var stateEl = document.getElementById("review-state");
34
40
  var saveButton = document.getElementById("save");
35
41
  var cancelButton = document.getElementById("cancel");
36
42
  var outlineList = document.getElementById("outline-list");
43
+ var progressEl = document.getElementById("progress-fill");
44
+ var keysDialog = document.getElementById("keys-dialog");
37
45
 
38
46
  function el(tag, className) {
39
47
  var node = document.createElement(tag);
@@ -48,14 +56,47 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
48
56
  statusEl.className = kind ? "bf-status bf-status--" + kind : "bf-status";
49
57
  }
50
58
 
59
+ // The always-visible header chip: connection, comment count, and whether
60
+ // there are edits the file does not have yet.
61
+ function updateState() {
62
+ if (!stateEl) return;
63
+ var label;
64
+ var tone = "";
65
+ if (saved) {
66
+ label = "Saved";
67
+ tone = "success";
68
+ } else if (connected === true) {
69
+ label = "Connected";
70
+ tone = "success";
71
+ } else if (everConnected) {
72
+ // Only after the stream has opened once: an error before that is a
73
+ // transient initial connect, so keep showing Connecting rather than Offline.
74
+ label = "Offline";
75
+ tone = "danger";
76
+ } else {
77
+ label = "Connecting\u2026";
78
+ }
79
+ var count = annotations.length;
80
+ label += " \u00b7 " + count + (count === 1 ? " comment" : " comments");
81
+ if (dirty && !saved) {
82
+ label += " \u00b7 unsaved";
83
+ if (!tone) tone = "warn";
84
+ }
85
+ stateEl.textContent = label;
86
+ stateEl.className = "bf-chip bf-chip--accent review-state" + (tone ? " bf-chip--" + tone : "");
87
+ }
88
+
51
89
  function safeHref(url) {
52
90
  var trimmed = String(url).trim();
91
+ if (/^[\\/]{2}/.test(trimmed)) return null;
53
92
  if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
54
93
  if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return null;
55
94
  return trimmed;
56
95
  }
57
96
 
58
- var INLINE = /(\x60[^\x60]+\x60|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
97
+ // Inline token order matters: code, emphasis, and links are matched before a
98
+ // bare URL, so a URL inside a link target or a code span is never re-split.
99
+ var INLINE = /(\x60[^\x60]+\x60|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|(?:https?:\/\/|www\.)[^\s<]+)/g;
59
100
 
60
101
  function renderInline(container, text) {
61
102
  String(text).split(INLINE).forEach(function (part) {
@@ -76,6 +117,25 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
76
117
  var link = el("a"); link.textContent = match[1]; link.href = href; link.target = "_blank"; link.rel = "noreferrer noopener";
77
118
  container.appendChild(link); return;
78
119
  }
120
+ // GFM extended autolink: a bare http(s) or www. URL in running text.
121
+ if ((match = /^(?:https?:\/\/|www\.)[^\s<]+$/.exec(part))) {
122
+ var url = match[0].replace(/[.,;:!?*_~]+$/, "");
123
+ while (url.charAt(url.length - 1) === ")" && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
124
+ url = url.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
125
+ }
126
+ var dot = url.indexOf(".");
127
+ var target = /^www\./i.test(url) ? "http://" + url : url;
128
+ var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target) : null;
129
+ if (safe) {
130
+ var auto = el("a");
131
+ auto.textContent = url;
132
+ auto.href = safe;
133
+ auto.target = "_blank";
134
+ auto.rel = "noreferrer noopener";
135
+ container.appendChild(auto);
136
+ return;
137
+ }
138
+ }
79
139
  container.appendChild(document.createTextNode(part));
80
140
  });
81
141
  }
@@ -127,6 +187,53 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
127
187
  wrap.appendChild(pre);
128
188
  }
129
189
 
190
+ // A GFM table: split each pipe row into cells and honour the alignment row.
191
+ function splitRow(line) {
192
+ var text = String(line).trim();
193
+ if (text.charAt(0) === "|") text = text.slice(1);
194
+ if (text.charAt(text.length - 1) === "|") text = text.slice(0, -1);
195
+ return text.split("|").map(function (cell) { return cell.trim(); });
196
+ }
197
+
198
+ function columnAlign(delim) {
199
+ return splitRow(delim).map(function (cell) {
200
+ var left = cell.charAt(0) === ":";
201
+ var right = cell.charAt(cell.length - 1) === ":";
202
+ return left && right ? "center" : right ? "right" : left ? "left" : "";
203
+ });
204
+ }
205
+
206
+ function renderTable(wrap, block) {
207
+ var rows = block.lines.map(splitRow);
208
+ var align = columnAlign(block.lines[1] || "");
209
+ var table = el("table", "review-table");
210
+ var thead = el("thead");
211
+ var headRow = el("tr");
212
+ rows[0].forEach(function (cell, index) {
213
+ var th = el("th");
214
+ if (align[index]) th.style.textAlign = align[index];
215
+ renderInline(th, cell);
216
+ headRow.appendChild(th);
217
+ });
218
+ thead.appendChild(headRow);
219
+ table.appendChild(thead);
220
+ var tbody = el("tbody");
221
+ rows.slice(2).forEach(function (cells) {
222
+ var tr = el("tr");
223
+ rows[0].forEach(function (unused, index) {
224
+ var td = el("td");
225
+ if (align[index]) td.style.textAlign = align[index];
226
+ renderInline(td, cells[index] || "");
227
+ tr.appendChild(td);
228
+ });
229
+ tbody.appendChild(tr);
230
+ });
231
+ table.appendChild(tbody);
232
+ var scroll = el("div", "review-table-wrap");
233
+ scroll.appendChild(table);
234
+ wrap.appendChild(scroll);
235
+ }
236
+
130
237
  function renderBlockContent(wrap, block) {
131
238
  var lines = Array.isArray(block.lines) ? block.lines : [];
132
239
  if (block.kind === "heading") {
@@ -138,6 +245,8 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
138
245
  return;
139
246
  }
140
247
  if (block.kind === "code") { renderCode(wrap, block); return; }
248
+ if (block.kind === "table") { renderTable(wrap, block); return; }
249
+ if (block.kind === "hr") { wrap.appendChild(el("hr")); return; }
141
250
  if (block.kind === "quote") {
142
251
  var quote = el("blockquote");
143
252
  renderInline(quote, lines.map(function (line) { return line.replace(/^\s*>\s?/, ""); }).join("\n"));
@@ -197,6 +306,15 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
197
306
  return box;
198
307
  }
199
308
 
309
+ // Enter submits a comment; Shift+Enter is left for the browser.
310
+ function submitOnEnter(area, submit) {
311
+ area.addEventListener("keydown", function (event) {
312
+ if (event.key !== "Enter" || event.shiftKey) return;
313
+ event.preventDefault();
314
+ submit();
315
+ });
316
+ }
317
+
200
318
  function openComposer(wrap, block, quote) {
201
319
  closeComposer();
202
320
  wrap.classList.add("bf-block--composing");
@@ -207,19 +325,23 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
207
325
  composer.appendChild(chip);
208
326
  }
209
327
  var area = el("textarea", "bf-textarea");
210
- area.placeholder = "Leave feedback on this part of the plan";
328
+ area.placeholder = "Leave feedback on this part of the plan (Enter to add)";
211
329
  area.setAttribute("aria-label", "Comment");
212
330
  composer.appendChild(area);
213
331
  var row = el("div", "bf-composer__row");
214
332
  var cancel = el("button"); cancel.type = "button"; cancel.className = "bf-btn"; cancel.textContent = "Cancel";
215
333
  cancel.addEventListener("click", closeComposer);
216
334
  var add = el("button"); add.type = "button"; add.className = "bf-btn bf-btn--primary"; add.textContent = "Add comment";
217
- add.addEventListener("click", function () {
335
+ var submit = function () {
218
336
  var comment = area.value.replace(/\s+/g, " ").trim();
219
337
  if (!comment) { area.focus(); return; }
338
+ if (annotations.length >= 200) { setStatus("Comment limit reached (200).", "error"); return; }
220
339
  annotations.push(quote ? { anchor: block.start, quote: quote, comment: comment } : { anchor: block.start, comment: comment });
340
+ dirty = true;
221
341
  renderPlan();
222
- });
342
+ };
343
+ add.addEventListener("click", submit);
344
+ submitOnEnter(area, submit);
223
345
  row.appendChild(cancel); row.appendChild(add); composer.appendChild(row);
224
346
  commentsBoxAfter(wrap).appendChild(composer);
225
347
  area.focus();
@@ -240,8 +362,10 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
240
362
  var comment = area.value.replace(/\s+/g, " ").trim();
241
363
  if (!comment) { area.focus(); return; }
242
364
  annotation.comment = comment;
365
+ dirty = true;
243
366
  renderPlan();
244
367
  });
368
+ submitOnEnter(area, function () { apply.click(); });
245
369
  row.appendChild(cancel); row.appendChild(apply); card.appendChild(row);
246
370
  area.focus();
247
371
  }
@@ -250,6 +374,17 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
250
374
  list.forEach(function (annotation) {
251
375
  var index = annotations.indexOf(annotation);
252
376
  var card = el("div", "bf-comment");
377
+ var head = el("div", "bf-comment__head");
378
+ var label = el("span", "bf-comment__label"); label.textContent = "Review";
379
+ head.appendChild(label);
380
+ head.appendChild(el("span", "bf-comment__spacer"));
381
+ var actions = el("div", "bf-comment__actions");
382
+ var edit = el("button"); edit.type = "button"; edit.className = "bf-btn"; edit.textContent = "Edit";
383
+ edit.addEventListener("click", function () { startEdit(card, index); });
384
+ var remove = el("button"); remove.type = "button"; remove.className = "bf-btn"; remove.textContent = "Delete";
385
+ remove.addEventListener("click", function () { annotations.splice(index, 1); dirty = true; renderPlan(); });
386
+ actions.appendChild(edit); actions.appendChild(remove); head.appendChild(actions);
387
+ card.appendChild(head);
253
388
  if (annotation.quote) {
254
389
  var quote = el("div", "bf-comment__quote");
255
390
  quote.textContent = "\u201c" + annotation.quote + "\u201d";
@@ -258,12 +393,6 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
258
393
  var body = el("div", "bf-comment__body");
259
394
  body.textContent = annotation.comment;
260
395
  card.appendChild(body);
261
- var row = el("div", "bf-comment__row");
262
- var edit = el("button"); edit.type = "button"; edit.className = "bf-btn"; edit.textContent = "Edit";
263
- edit.addEventListener("click", function () { startEdit(card, index); });
264
- var remove = el("button"); remove.type = "button"; remove.className = "bf-btn"; remove.textContent = "Delete";
265
- remove.addEventListener("click", function () { annotations.splice(index, 1); renderPlan(); });
266
- row.appendChild(edit); row.appendChild(remove); card.appendChild(row);
267
396
  box.appendChild(card);
268
397
  });
269
398
  }
@@ -295,11 +424,12 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
295
424
  var link = links[entry.target.id];
296
425
  if (link) link.setAttribute("aria-current", "true");
297
426
  });
298
- }, { rootMargin: "-76px 0px -68% 0px", threshold: 0 });
427
+ }, { rootMargin: "-88px 0px -68% 0px", threshold: 0 });
299
428
  headings.forEach(function (heading) { sectionObserver.observe(heading); });
300
429
  }
301
430
 
302
431
  function renderOutline() {
432
+ outlineItems = {};
303
433
  if (!outlineList) return;
304
434
  outlineList.replaceChildren();
305
435
  var items = Array.isArray(data.outline) ? data.outline : [];
@@ -308,15 +438,95 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
308
438
  var link = el("a");
309
439
  link.href = "#" + item.id;
310
440
  link.setAttribute("data-id", item.id);
311
- link.textContent = item.text;
441
+ var label = el("span", "review-nav__link");
442
+ label.textContent = item.text;
443
+ link.appendChild(label);
444
+ var count = el("span", "review-nav__count");
445
+ count.hidden = true;
446
+ link.appendChild(count);
312
447
  li.appendChild(link);
313
448
  outlineList.appendChild(li);
449
+ outlineItems[item.line] = count;
314
450
  });
315
451
  }
316
452
 
453
+ // The section a source line belongs to: the last heading at or before it.
454
+ function sectionFor(line) {
455
+ var items = Array.isArray(data.outline) ? data.outline : [];
456
+ var found = null;
457
+ items.forEach(function (item) { if (item.line <= line) found = item; });
458
+ return found;
459
+ }
460
+
461
+ // Review facts: the plan's step progress, and the comments per section.
462
+ function renderFacts() {
463
+ var items = Array.isArray(data.blocks) ? data.blocks : [];
464
+ var done = 0;
465
+ var total = 0;
466
+ items.forEach(function (block) {
467
+ if (block.kind !== "list") return;
468
+ (block.lines || []).forEach(function (line) {
469
+ var box = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]/.exec(line);
470
+ if (!box) return;
471
+ total += 1;
472
+ if (box[1].toLowerCase() === "x") done += 1;
473
+ });
474
+ });
475
+ var stepFill = document.getElementById("step-fill");
476
+ if (stepFill) stepFill.style.width = (total > 0 ? Math.round((done / total) * 100) : 0) + "%";
477
+ var stepCount = document.getElementById("step-count");
478
+ if (stepCount) stepCount.textContent = done + "/" + total;
479
+ var totalEl = document.getElementById("comment-total");
480
+ if (totalEl) totalEl.textContent = String(annotations.length);
481
+ Object.keys(outlineItems).forEach(function (line) { outlineItems[line].hidden = true; });
482
+ annotations.forEach(function (annotation) {
483
+ var section = sectionFor(annotation.anchor);
484
+ if (!section) return;
485
+ var count = outlineItems[section.line];
486
+ if (!count) return;
487
+ var value = count.hidden ? 0 : Number(count.textContent);
488
+ count.textContent = String(value + 1);
489
+ count.hidden = false;
490
+ });
491
+ updateState();
492
+ }
493
+
494
+ function blockElements() {
495
+ return planEl ? Array.prototype.slice.call(planEl.querySelectorAll(".bf-block")) : [];
496
+ }
497
+
498
+ function setActive(index) {
499
+ var blocks = blockElements();
500
+ if (blocks.length === 0) return;
501
+ activeIndex = Math.max(0, Math.min(blocks.length - 1, index));
502
+ blocks.forEach(function (block, at) { block.classList.toggle("bf-block--active", at === activeIndex); });
503
+ var current = blocks[activeIndex];
504
+ if (current && current.scrollIntoView) current.scrollIntoView({ block: "nearest" });
505
+ }
506
+
507
+ function moveActive(delta) {
508
+ var blocks = blockElements();
509
+ if (blocks.length === 0) return;
510
+ if (activeIndex < 0) { setActive(delta < 0 ? blocks.length - 1 : 0); return; }
511
+ setActive(activeIndex + delta);
512
+ }
513
+
514
+ function commentOnActive() {
515
+ var blocks = blockElements();
516
+ if (blocks.length === 0) return;
517
+ if (activeIndex < 0) setActive(0);
518
+ var wrap = blockElements()[Math.max(0, activeIndex)];
519
+ if (!wrap) return;
520
+ var line = Number(wrap.getAttribute("data-line"));
521
+ var block = (data.blocks || []).filter(function (item) { return item.start === line; })[0];
522
+ if (block) openComposer(wrap, block, "");
523
+ }
524
+
317
525
  function renderPlan() {
318
526
  if (!planEl) return;
319
527
  closeComposer();
528
+ pendingQuote = null;
529
+ var scrollTop = planEl.scrollTop;
320
530
  planEl.replaceChildren();
321
531
  var blocks = Array.isArray(data.blocks) ? data.blocks : [];
322
532
  var anchored = {};
@@ -328,19 +538,27 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
328
538
  wrap.setAttribute("data-kind", String(block.kind));
329
539
  if (block.kind === "heading") wrap.id = "s-" + block.start;
330
540
  renderBlockContent(wrap, block);
331
- var add = el("button", "bf-btn bf-btn--ghost bf-block__add");
541
+ var mine = annotations.filter(function (annotation) { return annotation.anchor === block.start; });
542
+ if (mine.length > 0) wrap.classList.add("bf-block--commented");
543
+ var rail = el("div", "bf-block__rail");
544
+ var add = el("button", "bf-block__add");
332
545
  add.type = "button";
546
+ add.textContent = "+";
333
547
  add.setAttribute("aria-label", "Comment on this block");
334
- add.appendChild(document.createTextNode("+"));
335
- var addLabel = el("span", "bf-block__add-label");
336
- addLabel.textContent = "Comment";
337
- add.appendChild(addLabel);
338
548
  add.addEventListener("click", function () {
339
549
  var quote = pendingQuote && pendingQuote.wrap === wrap ? pendingQuote.quote : selectionIn(wrap);
340
550
  pendingQuote = null;
341
551
  openComposer(wrap, block, quote);
342
552
  });
343
- wrap.appendChild(add);
553
+ rail.appendChild(add);
554
+ if (mine.length > 0) {
555
+ var badge = el("span", "bf-block__count");
556
+ badge.textContent = String(mine.length);
557
+ badge.title = mine.length === 1 ? "1 review comment" : mine.length + " review comments";
558
+ rail.appendChild(badge);
559
+ }
560
+ wrap.appendChild(rail);
561
+ wrap.addEventListener("focusin", function () { setActive(blockElements().indexOf(wrap)); });
344
562
  planEl.appendChild(wrap);
345
563
  renderComments(planEl, block.start);
346
564
  });
@@ -352,7 +570,10 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
352
570
  renderCommentCards(box, orphans);
353
571
  planEl.appendChild(box);
354
572
  }
573
+ planEl.scrollTop = scrollTop;
574
+ renderFacts();
355
575
  observeSections();
576
+ updateState();
356
577
  }
357
578
 
358
579
  function renderHeader() {
@@ -364,11 +585,39 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
364
585
  if (data.title) document.title = "Plan review \u00b7 " + data.title;
365
586
  }
366
587
 
588
+ function updateProgress() {
589
+ if (!progressEl) return;
590
+ var doc = document.scrollingElement || document.documentElement;
591
+ var top = doc ? doc.scrollTop : 0;
592
+ var height = doc ? doc.scrollHeight : 0;
593
+ var view = doc ? doc.clientHeight : 0;
594
+ if (planEl && planEl.scrollHeight > planEl.clientHeight + 4) {
595
+ top = planEl.scrollTop; height = planEl.scrollHeight; view = planEl.clientHeight;
596
+ }
597
+ var max = height - view;
598
+ var percent = max > 0 ? Math.min(100, Math.max(0, (top / max) * 100)) : 0;
599
+ progressEl.style.width = percent + "%";
600
+ }
601
+
367
602
  function lockActions() {
368
603
  if (saveButton) saveButton.disabled = true;
369
604
  if (cancelButton) cancelButton.disabled = true;
370
605
  }
371
606
 
607
+ // A conflict means the file moved under the page; only a reload can re-anchor.
608
+ function offerReload() {
609
+ if (document.getElementById("reload")) return;
610
+ var controls = document.querySelector(".review-controls");
611
+ if (!controls) return;
612
+ var reload = el("button");
613
+ reload.type = "button";
614
+ reload.id = "reload";
615
+ reload.className = "bf-btn bf-btn--primary";
616
+ reload.textContent = "Reload";
617
+ reload.addEventListener("click", function () { window.location.reload(); });
618
+ controls.appendChild(reload);
619
+ }
620
+
372
621
  function save() {
373
622
  if (saved) return;
374
623
  setStatus("Saving\u2026");
@@ -377,10 +626,21 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
377
626
  headers: { "Content-Type": "application/json" },
378
627
  body: JSON.stringify({ annotations: annotations })
379
628
  }).then(function (response) {
380
- if (!response.ok) throw new Error("server returned " + response.status);
381
- saved = true;
382
- setStatus("Saved \u2014 return to Pi to continue.", "saved");
383
- lockActions();
629
+ if (response.ok) {
630
+ saved = true;
631
+ dirty = false;
632
+ setStatus("Saved \u2014 return to Pi to continue.", "saved");
633
+ lockActions();
634
+ updateState();
635
+ return;
636
+ }
637
+ if (response.status === 409) {
638
+ setStatus("The plan changed on disk \u2014 reload to review again.", "error");
639
+ if (saveButton) saveButton.disabled = true;
640
+ offerReload();
641
+ return;
642
+ }
643
+ throw new Error("server returned " + response.status);
384
644
  }).catch(function (error) {
385
645
  setStatus("Could not save: " + error.message, "error");
386
646
  });
@@ -399,8 +659,8 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
399
659
  if (saveButton) saveButton.addEventListener("click", save);
400
660
  if (cancelButton) cancelButton.addEventListener("click", cancel);
401
661
 
402
- // Remember a selection made inside a block so clicking + Comment can still
403
- // quote it after the button press has collapsed the selection.
662
+ // Remember a selection made inside a block so clicking the rail button can
663
+ // still quote it after the button press has collapsed the selection.
404
664
  document.addEventListener("mouseup", function (event) {
405
665
  if (!planEl || saved) return;
406
666
  var wrap = event.target && event.target.closest ? event.target.closest(".bf-block") : null;
@@ -408,8 +668,40 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
408
668
  pendingQuote = wrap && quote ? { wrap: wrap, quote: quote } : null;
409
669
  });
410
670
 
671
+ function toggleKeys(force) {
672
+ if (!keysDialog) return;
673
+ var open = force !== undefined ? force : keysDialog.hidden;
674
+ keysDialog.hidden = !open;
675
+ if (open) {
676
+ var close = document.getElementById("keys-close");
677
+ if (close) close.focus();
678
+ }
679
+ }
680
+
681
+ if (keysDialog) {
682
+ keysDialog.addEventListener("click", function (event) { if (event.target === keysDialog) toggleKeys(false); });
683
+ var keysButton = document.getElementById("shortcuts");
684
+ if (keysButton) keysButton.addEventListener("click", function () { toggleKeys(); });
685
+ var keysClose = document.getElementById("keys-close");
686
+ if (keysClose) keysClose.addEventListener("click", function () { toggleKeys(false); });
687
+ }
688
+
689
+ function typing(target) {
690
+ var tag = target && target.tagName ? target.tagName : "";
691
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (target && target.isContentEditable);
692
+ }
693
+
411
694
  document.addEventListener("keydown", function (event) {
412
- if (event.key === "Escape") closeComposer();
695
+ if (event.key === "Escape") { closeComposer(); toggleKeys(false); return; }
696
+ if (typing(event.target)) return;
697
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
698
+ if (event.key === "?") { event.preventDefault(); toggleKeys(); return; }
699
+ // While the shortcut sheet is open it owns the keyboard.
700
+ if (keysDialog && !keysDialog.hidden) return;
701
+ if (event.key === "j") { event.preventDefault(); moveActive(1); return; }
702
+ if (event.key === "k") { event.preventDefault(); moveActive(-1); return; }
703
+ if (event.key === "c") { event.preventDefault(); commentOnActive(); return; }
704
+ if (event.key === "s") { event.preventDefault(); save(); return; }
413
705
  });
414
706
 
415
707
  window.addEventListener("beforeunload", function () {
@@ -418,10 +710,22 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
418
710
  if (navigator.sendBeacon) navigator.sendBeacon(url);
419
711
  });
420
712
 
713
+ window.addEventListener("scroll", updateProgress);
714
+ window.addEventListener("resize", updateProgress);
715
+ if (planEl) planEl.addEventListener("scroll", updateProgress);
716
+
421
717
  try {
422
- new EventSource("/events?token=" + encodeURIComponent(token));
718
+ var stream = new EventSource("/events?token=" + encodeURIComponent(token));
719
+ stream.addEventListener("open", function () { connected = true; everConnected = true; updateState(); });
720
+ stream.addEventListener("error", function () {
721
+ // The server ends the stream on save; keep showing Saved rather than Offline.
722
+ if (!saved) { connected = false; updateState(); }
723
+ });
423
724
  } catch (error) {
424
725
  // The server still sees the POST; the stream is only for close detection.
726
+ connected = false;
727
+ everConnected = true;
728
+ updateState();
425
729
  }
426
730
 
427
731
  ${THEME_TOGGLE_JS}
@@ -429,5 +733,6 @@ export const REVIEW_CLIENT_JS = String.raw`(function () {
429
733
  renderHeader();
430
734
  renderOutline();
431
735
  renderPlan();
736
+ updateProgress();
432
737
  })();
433
738
  `;