slidemind 0.1.8 → 0.2.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.
@@ -164,25 +164,86 @@ export function listenWithFallback(server, port, host, onFallback) {
164
164
  }
165
165
 
166
166
  // Per-run heartbeat state for one gate server. The gate mounts `respond` under
167
- // its own heartbeat path; a waiting tab polls it and reloads only when BOTH the
168
- // service marker matches its own gate (a foreign app or the other gate on this
169
- // port must never trigger a reload) AND the round nonce differs from the one it
170
- // was served with (same nonce = this run's own orphan — never self-reload).
171
- // `waitForHeartbeat(ms)` is the server side of the open-browser grace window.
167
+ // its own heartbeat path; a waiting tab polls it with its own round nonce and
168
+ // visibility state, and the response's service marker + round nonce + `action`
169
+ // tell it what to do (a foreign app or the other gate on this port must never
170
+ // trigger a reload; same nonce = this run's own orphan — never a waiter).
171
+ //
172
+ // The gate must ALWAYS end up visibly open in front of the user, so tab reuse
173
+ // is granted only to a waiting tab that reports itself VISIBLE (`visible: true`,
174
+ // from the overlay's document.visibilityState) inside the grace window. A tab
175
+ // buried in a background window must not silently swallow the round — it keeps
176
+ // polling (`action: "wait"`) until the grace window decides, then either
177
+ // reloads (`"reload"`, a visible tab won reuse — every waiter follows it into
178
+ // the new round) or is told the round opened in a fresh browser tab and it
179
+ // should retire (`"close"`).
180
+ //
181
+ // `waitForHeartbeat(ms)` is the server side of the open-browser grace window:
182
+ // it resolves true the moment a visible waiter claims reuse, false when the
183
+ // grace expires with none. The gate then calls `markFresh()` before opening a
184
+ // new browser tab so late pollers get `action: "close"` instead of reloading
185
+ // into a duplicate.
186
+ //
187
+ // `notePageLoad()` / `waitForPageLoad(ms)` / `pageLoaded()` track whether the
188
+ // gate PAGE was ever actually fetched: the gate's request handler calls
189
+ // `notePageLoad()` whenever it serves the root page, and serveAndOpen waits on
190
+ // it after launching the opener — the only end-to-end proof that a browser
191
+ // really opened the gate (a spawned opener exiting 0 proves nothing on a box
192
+ // with a broken default-browser association). `pageLoaded()` is re-checked at
193
+ // decision-timeout time so the DECISION: pending contract can tell the agent
194
+ // whether the user ever saw the gate at all.
172
195
  export function createHeartbeat({ service }) {
173
196
  const round = crypto.randomUUID();
174
- let seen = false;
197
+ // null = undecided (grace window running), "reuse" = a visible waiting tab
198
+ // reloads into this round, "fresh" = a new browser tab was opened for it.
199
+ let mode = null;
175
200
  const waiters = [];
201
+ let pageSeen = false;
202
+ const pageWaiters = [];
176
203
  return {
177
204
  round,
178
- respond(res) {
179
- seen = true;
180
- while (waiters.length) waiters.shift()();
205
+ notePageLoad() {
206
+ pageSeen = true;
207
+ while (pageWaiters.length) pageWaiters.shift()();
208
+ },
209
+ pageLoaded() {
210
+ return pageSeen;
211
+ },
212
+ waitForPageLoad(ms) {
213
+ if (pageSeen) return Promise.resolve(true);
214
+ if (!(ms > 0)) return Promise.resolve(false);
215
+ return new Promise((resolve) => {
216
+ const t = setTimeout(() => resolve(false), ms);
217
+ pageWaiters.push(() => {
218
+ clearTimeout(t);
219
+ resolve(true);
220
+ });
221
+ });
222
+ },
223
+ respond(res, { visible = false, tabRound = null } = {}) {
224
+ // A poll carrying THIS run's nonce is our own page (or an orphan of this
225
+ // run) — never a previous-round waiter, never a reuse claim.
226
+ const isWaiter = tabRound !== round;
227
+ if (isWaiter && mode === null && visible) {
228
+ mode = "reuse";
229
+ while (waiters.length) waiters.shift()();
230
+ }
231
+ const action = !isWaiter
232
+ ? "none"
233
+ : mode === "reuse"
234
+ ? "reload"
235
+ : mode === "fresh"
236
+ ? "close"
237
+ : "wait";
181
238
  res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
182
- res.end(JSON.stringify({ ok: true, service, round }));
239
+ res.end(JSON.stringify({ ok: true, service, round, action }));
240
+ },
241
+ markFresh() {
242
+ if (mode === null) mode = "fresh";
183
243
  },
184
244
  waitForHeartbeat(ms) {
185
- if (seen) return Promise.resolve(true);
245
+ if (mode === "reuse") return Promise.resolve(true);
246
+ if (mode === "fresh" || !(ms > 0)) return Promise.resolve(false);
186
247
  return new Promise((resolve) => {
187
248
  const t = setTimeout(() => resolve(false), ms);
188
249
  waiters.push(() => {
@@ -194,30 +255,110 @@ export function createHeartbeat({ service }) {
194
255
  };
195
256
  }
196
257
 
197
- // Print the gate URL, then decide whether to open a browser. If a tab left
198
- // waiting from a previous comments round pings the gate's heartbeat within the
199
- // grace window, reuse it (it reloads itself) instead of opening a duplicate.
200
- // Returns true if an existing tab was reused. `open` is required — gates pass
201
- // their browser opener so this module stays dependency-light and tests inject
202
- // a fake. The message strings are per-gate; defaults speak the preview gate's.
258
+ // Browser-status lines for a gate's DECISION: pending output, shared by both
259
+ // gates so the wording is identical. A pending decision has two very different
260
+ // stories: the user saw the gate and hasn't answered (benign — just wait), or
261
+ // no tab ever loaded it (the browser open failed and they never saw anything —
262
+ // the agent must hand them the link). `pageLoaded` comes from the server's
263
+ // page-load tracker re-checked at timeout time, so a tab that opened late still
264
+ // counts. Returns [] when the caller has no status to report (url unknown),
265
+ // keeping older call sites and tests valid.
266
+ export function pendingBrowserLines({ pageLoaded = null, url = null } = {}) {
267
+ if (!url) return [];
268
+ if (pageLoaded) {
269
+ return [
270
+ `Browser status: the gate DID open in the user's browser (a tab loaded ${url})`,
271
+ "— they just haven't decided yet.",
272
+ ];
273
+ }
274
+ return [
275
+ "Browser status: NO tab ever loaded the gate — the browser most likely FAILED",
276
+ "to open on the user's machine, so they never saw it. When you tell the user,",
277
+ `give them the gate link to open manually once you re-run: ${url}`,
278
+ "(the re-run binds the same address when its port is free, and prints it again).",
279
+ ];
280
+ }
281
+
282
+ // How long serveAndOpen waits, after launching the opener, for the fresh tab
283
+ // to actually fetch the gate page before reporting the open as unconfirmed.
284
+ // Generous enough for a cold browser start; the gate blocks on the decision
285
+ // anyway, so this delay only defers one status line.
286
+ export const OPEN_CONFIRM_MS = 8000;
287
+
288
+ // When the opener chain FAILED outright, the user will never see the gate on
289
+ // their own — blocking the agent for the full decision timeout helps nobody.
290
+ // The gates instead give a manually-opened tab this long to appear (the URL was
291
+ // printed, the user may click it from the terminal), then return the pending
292
+ // contract early so the agent can hand the user the link.
293
+ export const OPEN_FAILED_GRACE_MS = 90 * 1000;
294
+
295
+ // Print the gate URL, then decide whether to open a browser. If a VISIBLE tab
296
+ // left waiting from a previous comments round pings the gate's heartbeat within
297
+ // the grace window, reuse it (it reloads itself) instead of opening a duplicate.
298
+ // In every other case — first run, no waiting tab, or only hidden/background
299
+ // tabs polling — the browser is ALWAYS opened, and `markOpenedFresh` retires
300
+ // the hidden waiters so they don't reload into a duplicate.
301
+ //
302
+ // Opening a browser from a CLI is error-prone, so the outcome is REPORTED, not
303
+ // assumed: the opener's own result says whether anything launched, and the
304
+ // server's page-load tracker (`waitForPageLoad`, fed by the gate serving its
305
+ // root page) is the end-to-end proof a tab really opened. Every branch prints
306
+ // one agent-facing `BROWSER:` line that (a) says explicitly whether the gate
307
+ // auto-opened and (b) always carries the URL, so the agent can either tell the
308
+ // user "it's on your screen" or hand them the link to open manually.
309
+ //
310
+ // Returns { reused, launched, confirmed, url }: `launched` = an opener
311
+ // plausibly ran (null when reused — nothing was launched); `confirmed` = a tab
312
+ // provably fetched the gate page (null when the server exposes no
313
+ // waitForPageLoad, i.e. unknown). `open` is required — gates pass their browser
314
+ // opener so this module stays dependency-light and tests inject a fake; a
315
+ // legacy/fake opener returning undefined counts as launched (only an explicit
316
+ // `false` means the chain failed). The message nouns are per-gate; defaults
317
+ // speak the preview gate's.
203
318
  export async function serveAndOpen(server, {
204
319
  open,
205
320
  graceMs = GRACE_MS,
321
+ openConfirmMs = OPEN_CONFIRM_MS,
206
322
  readyLabel = "Preview review",
207
323
  reuseNoun = "review tab",
208
- openedLine = "Opened your browser — leave comments or approve, then return here.",
324
+ gateNoun = "review",
209
325
  waitLine = null,
210
326
  } = {}) {
211
327
  process.stderr.write(`${readyLabel} ready at ${server.url}\n`); // URL always printed first
212
328
  const reused = await server.waitForHeartbeat(graceMs);
329
+ let launched = null;
330
+ let confirmed = null;
213
331
  if (reused) {
214
- process.stderr.write(`Reusing your open ${reuseNoun} — no new browser window.\n`);
332
+ process.stderr.write(
333
+ `BROWSER: reused the user's already-open ${reuseNoun} at ${server.url} — it reloaded into this round; no new window was opened.\n`,
334
+ );
335
+ confirmed = true;
215
336
  } else {
216
- open(server.url);
217
- process.stderr.write(`${openedLine}\n`);
337
+ if (server.markOpenedFresh) server.markOpenedFresh();
338
+ launched = (await open(server.url)) !== false;
339
+ if (launched && server.waitForPageLoad) {
340
+ confirmed = await server.waitForPageLoad(openConfirmMs);
341
+ }
342
+ if (!launched) {
343
+ process.stderr.write(
344
+ `BROWSER: could NOT be opened automatically (no opener worked on this machine). ` +
345
+ `The user must open the ${gateNoun} themselves — give them this link: ${server.url}\n`,
346
+ );
347
+ } else if (confirmed === false) {
348
+ process.stderr.write(
349
+ `BROWSER: open was attempted but NO tab has loaded the ${gateNoun} yet — it may not have opened. ` +
350
+ `If the user doesn't see it, give them this link to open manually: ${server.url}\n`,
351
+ );
352
+ } else {
353
+ // confirmed === true (a tab fetched the page) or null (server can't track
354
+ // page loads — trust the opener's success).
355
+ process.stderr.write(
356
+ `BROWSER: opened automatically — the ${gateNoun} is on the user's screen at ${server.url}.\n`,
357
+ );
358
+ }
218
359
  }
219
360
  // Spoken to the AGENT running this command: blocking here is the designed
220
361
  // behavior, not a hang — don't kill the call because it sits quiet.
221
362
  if (waitLine) process.stderr.write(`${waitLine}\n`);
222
- return reused;
363
+ return { reused, launched, confirmed, url: server.url };
223
364
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Inline-edit round-trip for the plan gate's rendered markdown blocks.
3
+ *
4
+ * The plan preview renders each `contenteditable` block stamped with its
5
+ * on-disk source line (`data-md-line`, 1-based, from plan-md.mjs) and kind
6
+ * (`data-md-kind`). When the user commits an edit, `replaceRenderedMarkdownBlock`
7
+ * maps the block's edited innerText back into the markdown source at that
8
+ * line, preserving list markers, heading levels, and blockquote prefixes.
9
+ * `addEditableAttributes` injects the `contenteditable`/`spellcheck` attributes
10
+ * onto the editable block kinds only.
11
+ *
12
+ * Pure string logic — no DOM, no Office (unit-testable).
13
+ *
14
+ * 1:1 port of the add-in's plan-markdown-edit.ts. Top-level helpers are
15
+ * prefixed `pme…` to keep the single-IIFE browser bundle collision-free;
16
+ * exported API names match the add-in verbatim.
17
+ */
18
+
19
+ export function isEditableKind(kind) {
20
+ return (
21
+ kind === "p" ||
22
+ kind === "li" ||
23
+ kind === "blockquote" ||
24
+ /^h[1-6]$/.test(kind)
25
+ );
26
+ }
27
+
28
+ function pmeIsBlockStart(line) {
29
+ return (
30
+ /^\s*$/.test(line) ||
31
+ /^(#{1,6})\s+/.test(line) ||
32
+ /^\s*```/.test(line) ||
33
+ /^\s*>/.test(line) ||
34
+ /^\s*(?:[-*]\s+|\d+[.)]\s+)/.test(line) ||
35
+ (line.includes("|") && /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/.test(line))
36
+ );
37
+ }
38
+
39
+ function pmeGetParagraphRange(lines, index) {
40
+ let end = index + 1;
41
+ while (end < lines.length && !pmeIsBlockStart(lines[end] ?? "")) {
42
+ end++;
43
+ }
44
+ return { start: index, end };
45
+ }
46
+
47
+ function pmeNormalizeEditableText(text) {
48
+ return text
49
+ .replace(/\u00a0/g, " ")
50
+ .replace(/[ \t]+\n/g, "\n")
51
+ .trim();
52
+ }
53
+
54
+ function pmeMarkdownForEdit(kind, existingLine, text) {
55
+ const normalized = pmeNormalizeEditableText(text);
56
+ if (!normalized) return null;
57
+
58
+ const heading = normalized.match(/^(#{1,6})\s+(.*)$/);
59
+ if (heading) {
60
+ return `${heading[1]} ${(heading[2] ?? "").trim()}`;
61
+ }
62
+
63
+ const list = normalized.match(/^((?:[-*])|\d+[.)])\s+(.*)$/);
64
+ if (list) {
65
+ return `${list[1]} ${(list[2] ?? "").trim()}`;
66
+ }
67
+
68
+ if (/^h[1-6]$/.test(kind)) {
69
+ const current = existingLine.match(/^(#{1,6})\s+/)?.[1] ?? "##";
70
+ return `${current} ${normalized}`;
71
+ }
72
+
73
+ if (kind === "li") {
74
+ const marker = existingLine.match(/^\s*((?:[-*])|\d+[.)])\s+/)?.[1] ?? "-";
75
+ return `${marker} ${normalized}`;
76
+ }
77
+
78
+ if (kind === "blockquote") {
79
+ return normalized
80
+ .split("\n")
81
+ .map((line) => `> ${line.trim()}`)
82
+ .join("\n");
83
+ }
84
+
85
+ return normalized;
86
+ }
87
+
88
+ /**
89
+ * Maps an edited rendered block's innerText back into the markdown source at
90
+ * the stamped 1-based line. Options: { markdown, line, kind, text }.
91
+ * No-op for non-editable kinds or out-of-range lines.
92
+ */
93
+ export function replaceRenderedMarkdownBlock(options) {
94
+ const { markdown, line, kind, text } = options;
95
+ if (!isEditableKind(kind)) return markdown;
96
+
97
+ const lines = markdown.split("\n");
98
+ const index = line - 1;
99
+ if (index < 0 || index >= lines.length) return markdown;
100
+
101
+ const existingLine = lines[index] ?? "";
102
+ const range =
103
+ kind === "p"
104
+ ? pmeGetParagraphRange(lines, index)
105
+ : { start: index, end: index + 1 };
106
+ const replacement = pmeMarkdownForEdit(kind, existingLine, text);
107
+ const nextLines = [
108
+ ...lines.slice(0, range.start),
109
+ ...(replacement === null ? [] : replacement.split("\n")),
110
+ ...lines.slice(range.end),
111
+ ];
112
+ return nextLines.join("\n");
113
+ }
114
+
115
+ export function addEditableAttributes(html) {
116
+ return html.replace(
117
+ /data-md-kind="(h[1-6]|p|li|blockquote)"/g,
118
+ 'data-md-kind="$1" contenteditable="true" spellcheck="true"',
119
+ );
120
+ }