@gavin-hu/my-pi-agent 0.8.0 → 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.
- package/README.md +9 -8
- package/README.zh-CN.md +8 -8
- package/assets/file-browse.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +49 -36
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +6 -5
- package/extensions/bytefly/footer/config.ts +1 -1
- package/extensions/bytefly/footer/render/format.ts +5 -18
- package/extensions/bytefly/footer/render/layout.ts +2 -1
- package/extensions/bytefly/footer/render/segments.ts +51 -78
- package/extensions/bytefly/header/component.ts +33 -16
- package/extensions/bytefly/header/config.ts +28 -18
- package/extensions/bytefly/index.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +69 -35
- package/extensions/bytefly/panel/format.ts +8 -8
- package/extensions/bytefly/panel/tui.ts +45 -70
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +13 -40
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/document/README.md +1 -1
- package/extensions/document/render.ts +11 -10
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +62 -14
- package/extensions/file-browse/client.ts +103 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +176 -1
- package/extensions/file-browse/html.ts +412 -23
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +98 -5
- package/extensions/file-browse/router.ts +94 -2
- package/extensions/file-browse/server.ts +4 -1
- package/extensions/goal/format.ts +11 -10
- package/extensions/goal/tools.ts +6 -5
- package/extensions/goal/tui.ts +1 -1
- package/extensions/job/README.md +2 -2
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +1 -1
- package/extensions/job/tools.ts +11 -10
- package/extensions/job/tui.ts +141 -220
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +64 -34
- package/extensions/plan/blocks.ts +44 -2
- package/extensions/plan/feedback.ts +20 -5
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/runtime.ts +3 -2
- package/extensions/plan/tools.ts +23 -22
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +335 -30
- package/extensions/plan/web-page.ts +110 -30
- package/extensions/plan/web-router.ts +6 -4
- package/extensions/plan/web.ts +41 -10
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +12 -11
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +1 -1
- package/extensions/rewind/format.ts +29 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/render.ts +62 -47
- package/extensions/todo/README.md +2 -2
- package/extensions/todo/format.ts +31 -10
- package/extensions/todo/tools.ts +5 -4
- package/extensions/todo/tui.ts +45 -26
- package/extensions/watch/README.md +2 -2
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +1 -1
- package/extensions/watch/tools.ts +12 -7
- package/extensions/watch/tui.ts +135 -194
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +2 -2
- package/extensions/wechat/index.ts +4 -3
- package/extensions/wechat/login-screen.ts +8 -5
- package/extensions/wechat/status-screen.ts +41 -22
- package/extensions/wechat/tools.ts +6 -5
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/index.ts +1 -1
- package/extensions/worktree/runtime.ts +1 -1
- package/lib/README.md +10 -7
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +9 -30
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
- package/lib/tui/gauge.ts +78 -0
- package/lib/tui/index.ts +25 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +109 -0
- package/lib/tui/rules.ts +88 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +58 -0
- package/package.json +2 -2
- /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
package/extensions/plan/tui.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* continuation
|
|
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
|
-
|
|
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
|
|
148
|
-
const
|
|
149
|
-
const
|
|
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 (
|
|
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 (
|
|
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[] = [
|
|
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(
|
|
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
|
|
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
|
|
10
|
-
* connection open so the server can tell when the tab is
|
|
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
|
-
|
|
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
|
-
|
|
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: "-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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
|
`;
|