@levistudio/redline 0.2.0 → 0.4.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/src/cli.ts CHANGED
@@ -4,6 +4,10 @@ import { mkdir, writeFile } from "fs/promises";
4
4
  import { spawnSync } from "child_process";
5
5
  import { createRequire } from "module";
6
6
  import path from "path";
7
+ // reviewSummary only `import type`s from ./sidecar, so it pulls in no
8
+ // third-party deps at runtime — safe to import statically ahead of the
9
+ // preflight. abandon() needs it synchronously (signal context).
10
+ import { collectEscalations, formatReviewSummary } from "./reviewSummary";
7
11
 
8
12
  // Ensure redline's own dependencies are resolvable before any third-party
9
13
  // imports load. `redline` is invoked from arbitrary projects: in a checkout
@@ -41,24 +45,25 @@ preflightDependencies();
41
45
  // Dynamic imports so preflight runs before module resolution pulls in third-party deps.
42
46
  const { createServer } = await import("./server");
43
47
  const { resolve } = await import("./resolve");
48
+ const {
49
+ getAgentProvider,
50
+ invalidProviderMessage,
51
+ parseAgentProviderId,
52
+ resolveProviderId,
53
+ } = await import("./agentProvider");
44
54
 
45
- // Verify the `claude` CLI is reachable before we start, so first-run users
46
- // without Claude Code installed get a clear message instead of a silent agent
47
- // crash later (the agent process shells out to `claude -p`; without it, replies
48
- // fail and errors land in `.review/errors.log` where nobody looks).
49
- //
50
- // Resolution mirrors the runtime: prefer CLAUDE_CODE_EXECPATH (used by tests
51
- // and advanced setups), then look for `claude` on PATH.
52
- function preflightClaudeCli() {
53
- const exec = process.env.CLAUDE_CODE_EXECPATH;
54
- if (exec && existsSync(exec)) return;
55
- if (Bun.which("claude")) return;
56
- console.error(
57
- "\n[redline] Could not find the `claude` CLI on PATH.\n" +
58
- "Redline shells out to Claude Code for agent replies and revisions.\n" +
59
- "Install it from https://claude.com/claude-code and re-run.\n"
60
- );
61
- process.exit(1);
55
+ function argValue(args: string[], flag: string): string | undefined {
56
+ const idx = args.indexOf(flag);
57
+ return idx !== -1 ? args[idx + 1] : undefined;
58
+ }
59
+
60
+ function selectProvider(args: string[]) {
61
+ const raw = argValue(args, "--agent") ?? process.env.REDLINE_AGENT;
62
+ if (raw && !parseAgentProviderId(raw)) {
63
+ console.error(invalidProviderMessage(raw));
64
+ process.exit(1);
65
+ }
66
+ return getAgentProvider(resolveProviderId(raw));
62
67
  }
63
68
 
64
69
  // Walk up from `start` looking for a git root (a `.git` directory or file —
@@ -96,11 +101,33 @@ function maybePrintGitignoreHint(filePath: string) {
96
101
 
97
102
  const args = process.argv.slice(2);
98
103
 
104
+ if (args[0] === "--version" || args[0] === "-v") {
105
+ const pkgPath = path.resolve(import.meta.dir, "..", "package.json");
106
+ try {
107
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
108
+ console.log(pkg.version ?? "unknown");
109
+ } catch {
110
+ console.log("unknown");
111
+ }
112
+ process.exit(0);
113
+ }
114
+
115
+ // redline install-skill [--agent claude|codex|both]
116
+ if (args[0] === "install-skill") {
117
+ const script = path.resolve(import.meta.dir, "..", "scripts", "install-skill.sh");
118
+ if (!existsSync(script)) {
119
+ console.error(`Install script not found: ${script}`);
120
+ process.exit(1);
121
+ }
122
+ const result = spawnSync("bash", [script, ...args.slice(1)], { stdio: "inherit" });
123
+ process.exit(result.status ?? 1);
124
+ }
125
+
99
126
  // redline resolve <file> [--model <id>]
100
127
  if (args[0] === "resolve") {
101
128
  const filePath = args[1];
102
129
  if (!filePath) {
103
- console.error("Usage: redline resolve <file.md> [--model <model-id>]");
130
+ console.error("Usage: redline resolve <file.md> [--model <model-id>] [--agent claude|codex]");
104
131
  process.exit(1);
105
132
  }
106
133
  const resolved = path.resolve(filePath);
@@ -108,15 +135,20 @@ if (args[0] === "resolve") {
108
135
  console.error(`File not found: ${resolved}`);
109
136
  process.exit(1);
110
137
  }
111
- const modelFlag = args.indexOf("--model");
112
- const model = modelFlag !== -1 ? args[modelFlag + 1] : undefined;
113
- preflightClaudeCli();
114
- resolve(resolved, { model });
138
+ const model = argValue(args, "--model");
139
+ const provider = selectProvider(args);
140
+ try {
141
+ provider.preflight();
142
+ } catch (e) {
143
+ console.error(e instanceof Error ? e.message : String(e));
144
+ process.exit(1);
145
+ }
146
+ resolve(resolved, { model, agentProvider: provider.id });
115
147
  } else {
116
148
  // redline <file> — open review reader
117
149
  const filePath = args[0];
118
150
  if (!filePath) {
119
- console.error("Usage: redline <file.md>");
151
+ console.error("Usage: redline <file.md>\n redline resolve <file.md> [--model <model-id>] [--agent claude|codex]\n redline install-skill [--agent claude|codex|both]");
120
152
  process.exit(1);
121
153
  }
122
154
  const resolved = path.resolve(filePath);
@@ -125,18 +157,26 @@ if (args[0] === "resolve") {
125
157
  process.exit(1);
126
158
  }
127
159
  const noAgent = args.includes("--no-agent");
160
+ const provider = selectProvider(args);
128
161
 
129
162
  // Manual annotation mode skips both the preflight and the agent spawn —
130
- // the user just wants inline comments without a Claude conversation, so
131
- // requiring claude on PATH would be a hostile gate.
132
- if (!noAgent) preflightClaudeCli();
163
+ // the user just wants inline comments without an agent conversation, so
164
+ // requiring a provider CLI on PATH would be a hostile gate.
165
+ if (!noAgent) {
166
+ try {
167
+ provider.preflight();
168
+ } catch (e) {
169
+ console.error(e instanceof Error ? e.message : String(e));
170
+ process.exit(1);
171
+ }
172
+ }
133
173
 
134
- const contextFlag = args.indexOf("--context");
135
- const context = contextFlag !== -1 ? args[contextFlag + 1] : undefined;
174
+ const context = argValue(args, "--context");
136
175
  const autoOpen = args.includes("--open");
137
176
 
138
177
  const resultFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".result");
139
178
  const startupFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".startup.json");
179
+ const sidecarFile = path.join(path.dirname(resolved), ".review", path.basename(resolved) + ".json");
140
180
 
141
181
  // Clear stale state from a prior run so a polling agent can't be misled
142
182
  // by a leftover .result or .startup.json file that predates this process.
@@ -159,7 +199,7 @@ if (args[0] === "resolve") {
159
199
  // caller can pin the token if it needs to.
160
200
  const csrfToken = process.env.REDLINE_TOKEN ?? crypto.randomUUID();
161
201
 
162
- const app = createServer(resolved, { context, csrfToken, noAgent });
202
+ const app = createServer(resolved, { context, csrfToken, noAgent, agentName: provider.displayName });
163
203
  const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: app.fetch, idleTimeout: 0 });
164
204
  const url = `http://localhost:${server.port}`;
165
205
 
@@ -177,6 +217,7 @@ if (args[0] === "resolve") {
177
217
  started_at: new Date().toISOString(),
178
218
  pid: process.pid,
179
219
  csrf_token: csrfToken,
220
+ agent_provider: provider.id,
180
221
  }, null, 2));
181
222
  } catch (e) {
182
223
  console.error("[redline] Failed to write startup file:", e);
@@ -189,14 +230,14 @@ if (args[0] === "resolve") {
189
230
  console.log(` URL: ${url}`);
190
231
  console.log(` Result: ${resultFile}`);
191
232
  console.log(`${bar}`);
192
- if (noAgent) console.log(` Mode: manual annotation (--no-agent — no Claude replies, no revision pass)`);
233
+ if (noAgent) console.log(` Mode: manual annotation (--no-agent — no ${provider.displayName} replies, no revision pass)`);
193
234
  if (!autoOpen) console.log(`\n → cmd-click the URL when you're ready to review\n`);
194
235
  else console.log("");
195
236
 
196
237
  maybePrintGitignoreHint(resolved);
197
238
 
198
239
  // Auto-restart the agent if it dies unexpectedly (harness reaping, OOM,
199
- // a transient claude-CLI auth blip, etc). Capped to MAX_RESTARTS within
240
+ // a transient provider-CLI auth blip, etc). Capped to MAX_RESTARTS within
200
241
  // RESTART_WINDOW_MS so a permanently-broken environment doesn't loop forever.
201
242
  const RESTART_WINDOW_MS = 60_000;
202
243
  // Cap is overrideable via env so integration tests can exercise the
@@ -211,7 +252,7 @@ if (args[0] === "resolve") {
211
252
  [process.execPath, "run", path.join(import.meta.dir, "agent.ts"), resolved],
212
253
  {
213
254
  stdout: "inherit", stderr: "inherit", stdin: "ignore",
214
- env: { ...process.env, REDLINE_PORT: String(server.port), REDLINE_TOKEN: csrfToken },
255
+ env: { ...process.env, REDLINE_PORT: String(server.port), REDLINE_TOKEN: csrfToken, REDLINE_AGENT: provider.id },
215
256
  }
216
257
  );
217
258
  agentProc = proc;
@@ -274,19 +315,36 @@ if (args[0] === "resolve") {
274
315
  const status = lastRevisionError ? "error" : "abandoned";
275
316
  const payload: Record<string, unknown> = { status, file: resolved };
276
317
  if (lastRevisionError) payload.reason = lastRevisionError;
318
+ // Carry escalations through the error/abandon path too — on an incomplete
319
+ // session they matter more, not less. Read the sidecar synchronously:
320
+ // abandon runs in signal context where async I/O may not complete.
321
+ let escalations: import("./reviewSummary").EscalationItem[] = [];
322
+ try {
323
+ if (existsSync(sidecarFile)) {
324
+ const raw = readFileSync(sidecarFile, "utf-8");
325
+ if (raw.trim()) escalations = collectEscalations(JSON.parse(raw));
326
+ }
327
+ } catch { /* best effort — never block shutdown on the summary */ }
328
+ payload.escalations = escalations;
277
329
  // Synchronous write so the result file lands even if the runtime is
278
330
  // terminating due to a signal (async I/O may not complete in that case).
279
331
  try {
280
332
  mkdirSync(path.dirname(resultFile), { recursive: true });
281
333
  writeFileSync(resultFile, JSON.stringify(payload, null, 2));
282
334
  } catch { /* best effort */ }
283
- console.log(`\nREDLINE_RESULT: ${status}${lastRevisionError ? ` reason="${lastRevisionError}"` : ""}`);
335
+ if (escalations.length) {
336
+ console.log(
337
+ `\n⚠ ${escalations.length} comment${escalations.length !== 1 ? "s" : ""} escalated to the launching agent — see ${path.basename(resultFile)}`
338
+ );
339
+ }
340
+ const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
341
+ console.log(`\nREDLINE_RESULT: ${status}${lastRevisionError ? ` reason="${lastRevisionError}"` : ""}${escSuffix}`);
284
342
  // Exit 3 = revision error; 2 = abandoned. Both still distinguish from 0 (approved).
285
343
  process.exit(lastRevisionError ? 3 : 2);
286
344
  };
287
345
 
288
346
  // Happy-path finish: human clicked Done.
289
- app.onFinished(({ totalRounds, totalComments }) => {
347
+ app.onFinished(async ({ totalRounds, totalComments }) => {
290
348
  serverExiting = true;
291
349
  killAgent().catch(() => { /* shutdown already in flight */ });
292
350
  try { unlinkSync(startupFile); } catch { /* best effort */ }
@@ -296,10 +354,25 @@ if (args[0] === "resolve") {
296
354
  console.log(` ${totalRounds} round${totalRounds !== 1 ? "s" : ""} · ${totalComments} comment${totalComments !== 1 ? "s" : ""} addressed`);
297
355
  console.log(` Revised document: ${resolved}`);
298
356
  console.log(`${line}`);
357
+
358
+ // Print the full comment threads so the launching agent — which has no
359
+ // live channel to the inline review agent — sees everything the reviewer
360
+ // said, including escalated feedback meant for it.
361
+ let escalations: import("./reviewSummary").EscalationItem[] = [];
362
+ try {
363
+ const { loadSidecar } = await import("./sidecar");
364
+ const sidecar = await loadSidecar(resolved);
365
+ escalations = collectEscalations(sidecar);
366
+ console.log(`\n${formatReviewSummary(sidecar)}`);
367
+ } catch (e) {
368
+ console.error("[redline] Failed to build review summary:", e);
369
+ }
370
+
299
371
  // Machine-greppable result line for a calling agent. Keep this stable.
300
- console.log(`REDLINE_RESULT: approved file=${resolved} rounds=${totalRounds} comments=${totalComments}`);
372
+ const escSuffix = escalations.length ? ` escalations=${escalations.length}` : "";
373
+ console.log(`\nREDLINE_RESULT: approved file=${resolved} rounds=${totalRounds} comments=${totalComments}${escSuffix}`);
301
374
  console.log("");
302
- writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments })
375
+ writeResult({ status: "approved", file: resolved, rounds: totalRounds, comments: totalComments, escalations })
303
376
  .finally(() => process.exit(0));
304
377
  });
305
378
 
@@ -1,4 +1,4 @@
1
- import { escapeHtml, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
1
+ import { escapeHtml, isEscalated, latestVerdict, type ClientComment, type ThreadEntry } from "./lib";
2
2
  import { state } from "./state";
3
3
 
4
4
  export type CardCallbacks = {
@@ -41,6 +41,12 @@ export function buildCommentCard(comment: ClientComment): HTMLDivElement {
41
41
  quote.appendChild(vbadge);
42
42
  }
43
43
  }
44
+ if (isEscalated(comment)) {
45
+ const ebadge = document.createElement("span");
46
+ ebadge.className = "escalate-badge";
47
+ ebadge.textContent = "↑ Escalated";
48
+ quote.appendChild(ebadge);
49
+ }
44
50
  quote.appendChild(document.createTextNode('"' + comment.quote + '"'));
45
51
  card.appendChild(quote);
46
52
 
@@ -166,6 +172,9 @@ function buildThreadEntry(entry: ThreadEntry, isLatestVerdict: boolean): HTMLDiv
166
172
  const reason = entry.revision_reason ? escapeHtml(entry.revision_reason) : "edit queued";
167
173
  verdictHtml = `<div class="verdict revise"><span class="verdict-icon">\u270E</span><span>${reason}</span></div>`;
168
174
  }
175
+ if (role === "agent" && entry.escalate === true) {
176
+ verdictHtml += `<div class="verdict escalate"><span class="verdict-icon">\u2191</span><span>Routed to the launching agent</span></div>`;
177
+ }
169
178
  const messageHtml = entry.messageHtml ?? escapeHtml(entry.message);
170
179
  div.innerHTML = `
171
180
  <div class="thread-role ${role}">${escapeHtml(label)}</div>
package/src/client/lib.ts CHANGED
@@ -22,6 +22,7 @@ export type ThreadEntry = {
22
22
  messageHtml?: string;
23
23
  requires_revision?: boolean;
24
24
  revision_reason?: string;
25
+ escalate?: boolean;
25
26
  };
26
27
 
27
28
  export function escapeHtml(s: string): string {
@@ -44,6 +45,11 @@ export function latestVerdict(comment: ClientComment): "revise" | "accept" | nul
44
45
  return null;
45
46
  }
46
47
 
48
+ // True when an agent reply flagged this comment for the launching agent.
49
+ export function isEscalated(comment: ClientComment): boolean {
50
+ return (comment.thread || []).some((e) => e.role === "agent" && e.escalate === true);
51
+ }
52
+
47
53
  export function nearestCell(node: Node): HTMLElement | null {
48
54
  const el = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
49
55
  return el && (el as Element).closest ? ((el as Element).closest("td, th") as HTMLElement | null) : null;
@@ -86,63 +92,138 @@ export type Captured = {
86
92
  context_after: string;
87
93
  };
88
94
 
89
- // Walk the prose container's text nodes, locate the selection's start, and
90
- // return the quote with surrounding context. Returns null when the selection
91
- // can't be relocated against flat text (e.g. crossed an <img>).
92
- export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
93
- const range = sel.getRangeAt(0);
95
+ type FlatSegment = {
96
+ node: Text | HTMLImageElement;
97
+ start: number;
98
+ len: number;
99
+ isImg: boolean;
100
+ };
94
101
 
95
- const walker = (prose.ownerDocument || document).createTreeWalker(prose, NodeFilter.SHOW_TEXT);
96
- const segments: { node: Text; start: number }[] = [];
102
+ // An <img> contributes no characters of its own, so on its own it can't be
103
+ // anchored against. We give it a presence in the flat text as an
104
+ // `[image: alt]` token — the same shape the image-only comment path uses — so
105
+ // a selection can run text → image → text and still round-trip.
106
+ function imgToken(img: HTMLImageElement): string {
107
+ return "[image: " + (img.alt || "") + "]";
108
+ }
109
+
110
+ // Walk a container into a flat string plus the segments that produced it.
111
+ // Text nodes contribute their value; <img> elements contribute an
112
+ // `[image: alt]` token. Segments are in document order. captureSelection and
113
+ // highlightText both build flat through here so their coordinates agree.
114
+ function buildFlat(container: Element): { flat: string; segments: FlatSegment[] } {
115
+ const doc = container.ownerDocument || document;
116
+ const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
117
+ acceptNode(n: Node) {
118
+ if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT;
119
+ return (n as Element).tagName === "IMG" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
120
+ },
121
+ });
122
+ const segments: FlatSegment[] = [];
97
123
  let flat = "";
98
124
  let node: Node | null;
99
125
  while ((node = walker.nextNode())) {
100
- const tn = node as Text;
101
- segments.push({ node: tn, start: flat.length });
102
- flat += tn.nodeValue ?? "";
103
- }
104
-
105
- let quoteStart = -1;
106
- for (const seg of segments) {
107
- if (seg.node === range.startContainer) {
108
- quoteStart = seg.start + range.startOffset;
109
- break;
126
+ if (node.nodeType === Node.TEXT_NODE) {
127
+ const v = (node as Text).nodeValue ?? "";
128
+ segments.push({ node: node as Text, start: flat.length, len: v.length, isImg: false });
129
+ flat += v;
130
+ } else {
131
+ const img = node as HTMLImageElement;
132
+ const tok = imgToken(img);
133
+ segments.push({ node: img, start: flat.length, len: tok.length, isImg: true });
134
+ flat += tok;
110
135
  }
111
136
  }
137
+ return { flat, segments };
138
+ }
112
139
 
113
- if (quoteStart === -1) {
114
- return { quote: text, context_before: "", context_after: "" };
115
- }
140
+ // Map both range endpoints onto the flat text and return the quote with
141
+ // surrounding context. Returns null only when the selection can't be resolved
142
+ // against the flat text at all.
143
+ //
144
+ // The quote is sliced straight out of `flat` rather than reconstructed from
145
+ // `sel.toString()`. sel.toString() joins blocks with "\n"/"\n\n" separators
146
+ // that don't exist in the walker's output, and marked emits stray whitespace
147
+ // text nodes between block tags — so the two never line up across a block
148
+ // boundary. Working in flat coordinates sidesteps the mismatch: `flat` is the
149
+ // single source of truth, and highlightText re-finds the quote against the
150
+ // same flat string later. `text` is kept only as a last-resort fallback for
151
+ // the rare case where an endpoint can't be resolved.
152
+ export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
153
+ const range = sel.getRangeAt(0);
154
+ const { flat, segments } = buildFlat(prose);
155
+ if (segments.length === 0) return null;
116
156
 
117
- // Block boundaries: sel.toString() inserts "\n\n" between blocks (e.g. a
118
- // heading followed by a paragraph), but the text-node walker concatenates
119
- // with no separator. Strip those runs from `text` so it can match `flat`.
120
- // We keep single \n (which can appear inside a single text node, e.g. a
121
- // code block) untouched.
122
- const normalized = text.replace(/\n{2,}/g, "");
157
+ // Map a range boundary point onto an index into `flat`. Text-node boundaries
158
+ // are the common case; Chrome sometimes anchors a drag to an element node
159
+ // plus a child index, which we resolve to the nearest segment edge.
160
+ const pointToFlat = (container: Node, offset: number, side: "start" | "end"): number => {
161
+ for (const seg of segments) {
162
+ if (seg.node === container) {
163
+ if (seg.isImg) return offset > 0 ? seg.start + seg.len : seg.start;
164
+ return seg.start + offset;
165
+ }
166
+ }
167
+ if (container.nodeType === Node.TEXT_NODE) return -1;
168
+ const kids = container.childNodes;
169
+ const segEnd = (seg: FlatSegment) => seg.start + seg.len;
170
+ if (side === "start") {
171
+ const ref = offset < kids.length ? kids[offset]! : null;
172
+ if (ref) {
173
+ for (const seg of segments) {
174
+ if (ref === seg.node || ref.contains(seg.node)) return seg.start;
175
+ if (ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_FOLLOWING)
176
+ return seg.start;
177
+ }
178
+ return flat.length;
179
+ }
180
+ let end = -1;
181
+ for (const seg of segments) if (container.contains(seg.node)) end = segEnd(seg);
182
+ return end === -1 ? flat.length : end;
183
+ }
184
+ const ref = offset > 0 ? kids[offset - 1]! : null;
185
+ if (ref) {
186
+ let end = -1;
187
+ for (const seg of segments) {
188
+ if (
189
+ ref === seg.node ||
190
+ ref.contains(seg.node) ||
191
+ ref.compareDocumentPosition(seg.node) & Node.DOCUMENT_POSITION_PRECEDING
192
+ ) {
193
+ end = segEnd(seg);
194
+ }
195
+ }
196
+ return end === -1 ? 0 : end;
197
+ }
198
+ for (const seg of segments) if (container.contains(seg.node)) return seg.start;
199
+ return 0;
200
+ };
123
201
 
124
- const slice = flat.slice(quoteStart, quoteStart + normalized.length);
125
- if (slice !== normalized) {
126
- // Trimming in the caller can shift the true start a few chars away from
127
- // range.startOffset. Search a small window around quoteStart for the
128
- // normalized text before giving up.
129
- const windowStart = Math.max(0, quoteStart - 64);
130
- const windowEnd = Math.min(flat.length, quoteStart + normalized.length + 64);
131
- const found = flat.indexOf(normalized, windowStart);
132
- if (found === -1 || found >= windowEnd) return null;
133
- quoteStart = found;
202
+ let flatStart = pointToFlat(range.startContainer, range.startOffset, "start");
203
+ let flatEnd = pointToFlat(range.endContainer, range.endOffset, "end");
204
+ if (flatStart === -1 || flatEnd === -1 || flatEnd <= flatStart) {
205
+ return text ? { quote: text, context_before: "", context_after: "" } : null;
134
206
  }
135
207
 
208
+ // Trim whitespace overshoot: a drag that ends a hair past a block — or
209
+ // starts in the gap before one — shouldn't fail or carry stray newlines.
210
+ // This is the "clamp": a small overshoot anchors to what the user meant.
211
+ while (flatStart < flatEnd && /\s/.test(flat[flatStart]!)) flatStart++;
212
+ while (flatEnd > flatStart && /\s/.test(flat[flatEnd - 1]!)) flatEnd--;
213
+ if (flatEnd <= flatStart) return null;
214
+
136
215
  return {
137
- quote: normalized,
138
- context_before: flat.slice(Math.max(0, quoteStart - 32), quoteStart),
139
- context_after: flat.slice(quoteStart + normalized.length, quoteStart + normalized.length + 32),
216
+ quote: flat.slice(flatStart, flatEnd),
217
+ context_before: flat.slice(Math.max(0, flatStart - 32), flatStart),
218
+ context_after: flat.slice(flatEnd, flatEnd + 32),
140
219
  };
141
220
  }
142
221
 
143
222
  // Wrap occurrences of `text` inside `container` with <mark> elements. Uses
144
- // `contextBefore` to disambiguate when a quote appears multiple times.
145
- // Image quotes (`[image: alt]`) wrap the matching <img> instead.
223
+ // `contextBefore` to disambiguate when a quote appears multiple times. The
224
+ // quote is matched against the same flat text captureSelection produced, so
225
+ // `[image: alt]` tokens in the quote wrap the corresponding <img> — whether
226
+ // the quote is image-only or text mixed with an image.
146
227
  // Returns the marks created (caller can attach event listeners).
147
228
  export function highlightText(
148
229
  container: Element,
@@ -154,35 +235,9 @@ export function highlightText(
154
235
  const doc = container.ownerDocument || document;
155
236
  const marks: HTMLElement[] = [];
156
237
 
157
- const imgMatch = text.match(/^\[image:\s*(.*)\]$/);
158
- if (imgMatch) {
159
- const alt = imgMatch[1];
160
- const imgs = container.querySelectorAll("img");
161
- for (const img of imgs) {
162
- if ((img.alt || "") === alt) {
163
- const mark = doc.createElement("mark");
164
- mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
165
- mark.dataset.commentId = id;
166
- img.parentNode!.insertBefore(mark, img);
167
- mark.appendChild(img);
168
- marks.push(mark);
169
- return marks;
170
- }
171
- }
172
- return marks;
173
- }
174
-
175
238
  (container as HTMLElement).normalize();
176
239
 
177
- const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);
178
- const segments: { node: Text; start: number }[] = [];
179
- let flat = "";
180
- let node: Node | null;
181
- while ((node = walker.nextNode())) {
182
- const tn = node as Text;
183
- segments.push({ node: tn, start: flat.length });
184
- flat += tn.nodeValue ?? "";
185
- }
240
+ const { flat, segments } = buildFlat(container);
186
241
 
187
242
  let quoteStart = -1;
188
243
  if (contextBefore) {
@@ -194,18 +249,28 @@ export function highlightText(
194
249
 
195
250
  const quoteEnd = quoteStart + text.length;
196
251
 
197
- const toWrap: { node: Text; localStart: number; localEnd: number }[] = [];
198
252
  for (const seg of segments) {
199
- const segEnd = seg.start + (seg.node.nodeValue?.length ?? 0);
200
- if (segEnd <= quoteStart || seg.start >= quoteEnd) continue;
201
- toWrap.push({
202
- node: seg.node,
203
- localStart: Math.max(0, quoteStart - seg.start),
204
- localEnd: Math.min(seg.node.nodeValue?.length ?? 0, quoteEnd - seg.start),
205
- });
206
- }
253
+ const segStart = seg.start;
254
+ const segEnd = seg.start + seg.len;
255
+ if (segEnd <= quoteStart || segStart >= quoteEnd) continue;
256
+
257
+ if (seg.isImg) {
258
+ // Image tokens are atomic wrap the <img> only when the quote covers
259
+ // the whole token, never on a partial overlap.
260
+ if (segStart < quoteStart || segEnd > quoteEnd) continue;
261
+ const img = seg.node as HTMLImageElement;
262
+ const mark = doc.createElement("mark");
263
+ mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
264
+ mark.dataset.commentId = id;
265
+ img.parentNode!.insertBefore(mark, img);
266
+ mark.appendChild(img);
267
+ marks.push(mark);
268
+ continue;
269
+ }
207
270
 
208
- for (const { node: tn, localStart, localEnd } of toWrap) {
271
+ const tn = seg.node as Text;
272
+ const localStart = Math.max(0, quoteStart - segStart);
273
+ const localEnd = Math.min(seg.len, quoteEnd - segStart);
209
274
  const mark = doc.createElement("mark");
210
275
  mark.className = "rl-highlight" + (resolved ? " resolved" : "");
211
276
  mark.dataset.commentId = id;
@@ -3,8 +3,9 @@ import {
3
3
  highlightText as _highlightText,
4
4
  preserveScroll as _preserveScroll,
5
5
  latestVerdict,
6
+ isEscalated,
6
7
  } from "./lib";
7
- import { state, apiFetch, showError } from "./state";
8
+ import { state, apiFetch, showError, reportMutationFailure } from "./state";
8
9
  import {
9
10
  buildCommentCard,
10
11
  captureTypingState,
@@ -171,7 +172,7 @@ export async function saveComment(
171
172
  showError(data.error || "Failed to save comment");
172
173
  }
173
174
  } catch (err: unknown) {
174
- showError("Failed to save comment: " + (err as Error).message);
175
+ reportMutationFailure("Failed to save comment", err);
175
176
  }
176
177
  }
177
178
 
@@ -195,7 +196,7 @@ export async function submitReply(id: string, message: string): Promise<void> {
195
196
  showError(data.error || "Failed to save reply");
196
197
  }
197
198
  } catch (err: unknown) {
198
- showError("Failed to save reply: " + (err as Error).message);
199
+ reportMutationFailure("Failed to save reply", err);
199
200
  }
200
201
  }
201
202
 
@@ -220,7 +221,7 @@ export async function resolveComment(id: string): Promise<void> {
220
221
  showError(data.error || "Failed to resolve comment");
221
222
  }
222
223
  } catch (err: unknown) {
223
- showError("Failed to resolve: " + (err as Error).message);
224
+ reportMutationFailure("Failed to resolve", err);
224
225
  }
225
226
  }
226
227
 
@@ -240,7 +241,7 @@ export async function reopenComment(id: string): Promise<void> {
240
241
  showError(data.error || "Failed to reopen comment");
241
242
  }
242
243
  } catch (err: unknown) {
243
- showError("Failed to reopen: " + (err as Error).message);
244
+ reportMutationFailure("Failed to reopen", err);
244
245
  }
245
246
  }
246
247
 
@@ -345,6 +346,17 @@ export function applyRoundState(): void {
345
346
  bannerText = `${reviseCount} of ${total} comments imply edits.`;
346
347
  }
347
348
  banner.textContent = bannerText;
349
+ // Escalations are orthogonal to the revise/accept verdict — a comment
350
+ // can be answered in-thread yet still need the launching agent. Call
351
+ // it out on its own line so it survives all three banner branches.
352
+ const escCount = state.comments.filter(isEscalated).length;
353
+ if (escCount > 0) {
354
+ const escLine = document.createElement("div");
355
+ escLine.className = "banner-escalation";
356
+ escLine.textContent =
357
+ `↑ ${escCount} comment${escCount !== 1 ? "s" : ""} escalated to the launching agent.`;
358
+ banner.appendChild(escLine);
359
+ }
348
360
  banner.style.display = "block";
349
361
  }
350
362
 
@@ -197,7 +197,7 @@ export function initSelectionHandlers(): void {
197
197
 
198
198
  const captured = captureSelection(sel, text);
199
199
  if (!captured) {
200
- showError("Highlight a single passage \u2014 selections that cross images or sections can't be anchored.");
200
+ showError("Couldn't anchor that selection \u2014 try highlighting the passage again.");
201
201
  sel.removeAllRanges();
202
202
  return;
203
203
  }