@plannotator/ui 0.28.0 → 0.29.1

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 (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
package/utils/parser.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachment } from '../types';
2
2
  import { planDenyFeedback } from '@plannotator/core/feedback-templates';
3
+ import { skillReferenceExportBlock } from './skillReferences';
3
4
 
4
5
  /**
5
6
  * Parsed YAML frontmatter as key-value pairs.
@@ -8,6 +9,76 @@ export interface Frontmatter {
8
9
  [key: string]: string | string[];
9
10
  }
10
11
 
12
+ /** Number of leading whitespace characters on a line. */
13
+ function indentWidth(line: string): number {
14
+ return line.length - line.trimStart().length;
15
+ }
16
+
17
+ /** Strip the common leading indentation shared by all non-empty lines. */
18
+ function dedentLines(lines: string[]): string[] {
19
+ const indents = lines.filter((l) => l !== '').map(indentWidth);
20
+ const minIndent = indents.length ? Math.min(...indents) : 0;
21
+ return lines.map((l) => (l === '' ? '' : l.slice(minIndent)));
22
+ }
23
+
24
+ /**
25
+ * Fold YAML `>`-style scalar lines: adjacent non-empty lines join with a
26
+ * single space, and a run of N blank lines between paragraphs folds to N
27
+ * newlines.
28
+ */
29
+ function foldScalarLines(lines: string[]): string {
30
+ let text = '';
31
+ let started = false;
32
+ let blanks = 0;
33
+ for (const l of lines) {
34
+ if (l === '') {
35
+ blanks++;
36
+ continue;
37
+ }
38
+ if (!started) {
39
+ text = l;
40
+ started = true;
41
+ } else {
42
+ text += blanks > 0 ? '\n'.repeat(blanks) : ' ';
43
+ text += l;
44
+ }
45
+ blanks = 0;
46
+ }
47
+ return text;
48
+ }
49
+
50
+ /**
51
+ * Parse a YAML block scalar (`|` literal keeps newlines / `>` folded joins
52
+ * with spaces) whose body is the run of lines below `bodyStart` indented
53
+ * deeper than `keyIndent`. Trailing blank lines are dropped and chomping
54
+ * indicators are treated as strip. Returns the value and the index of the
55
+ * last line the scalar consumed.
56
+ */
57
+ function parseBlockScalar(
58
+ lines: string[],
59
+ bodyStart: number,
60
+ keyIndent: number,
61
+ folded: boolean,
62
+ ): { value: string; endIndex: number } {
63
+ const body: string[] = [];
64
+ let j = bodyStart;
65
+ for (; j < lines.length; j++) {
66
+ // CRLF sources split on '\n' leave a trailing '\r' that would otherwise
67
+ // survive into the folded value (every other parser path trims lines).
68
+ const bodyLine = lines[j].replace(/\r$/, '');
69
+ if (bodyLine.trim() === '') {
70
+ body.push('');
71
+ continue;
72
+ }
73
+ if (indentWidth(bodyLine) <= keyIndent) break; // dedent ends the block
74
+ body.push(bodyLine);
75
+ }
76
+ const dedented = dedentLines(body);
77
+ while (dedented.length && dedented[dedented.length - 1] === '') dedented.pop();
78
+ const value = (folded ? foldScalarLines(dedented) : dedented.join('\n')).trim();
79
+ return { value, endIndex: j - 1 };
80
+ }
81
+
11
82
  /**
12
83
  * Extract YAML frontmatter from markdown if present.
13
84
  * Returns the parsed frontmatter, the remaining markdown, and the 1-based
@@ -44,8 +115,10 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
44
115
  let currentKey: string | null = null;
45
116
  let currentArray: string[] | null = null;
46
117
 
47
- for (const line of frontmatterRaw.split('\n')) {
48
- const trimmedLine = line.trim();
118
+ const lines = frontmatterRaw.split('\n');
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const rawLine = lines[i];
121
+ const trimmedLine = rawLine.trim();
49
122
 
50
123
  // Array item (- value)
51
124
  if (trimmedLine.startsWith('- ') && currentKey) {
@@ -65,6 +138,27 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
65
138
  const value = trimmedLine.slice(colonIndex + 1).trim();
66
139
  currentArray = null;
67
140
 
141
+ // Block scalar: `|` (literal, keep newlines) or `>` (folded, join with
142
+ // spaces), each with optional chomping indicator (`-`/`+`). The value
143
+ // spans the following lines indented deeper than the key, e.g.
144
+ // description: >-
145
+ // line one
146
+ // line two
147
+ // Without this, the indicator (">-") was stored verbatim and the body
148
+ // silently dropped.
149
+ const blockScalar = value.match(/^([|>])[+-]?$/);
150
+ if (blockScalar) {
151
+ const { value: scalarValue, endIndex } = parseBlockScalar(
152
+ lines,
153
+ i + 1,
154
+ indentWidth(rawLine),
155
+ blockScalar[1] === '>',
156
+ );
157
+ frontmatter[currentKey] = scalarValue;
158
+ i = endIndex;
159
+ continue;
160
+ }
161
+
68
162
  if (value) {
69
163
  frontmatter[currentKey] = value;
70
164
  }
@@ -101,16 +195,372 @@ const VOID_HTML_TAGS: ReadonlySet<string> = new Set([
101
195
 
102
196
  const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s|>|\/|$)/;
103
197
 
198
+ export interface ParseMarkdownOptions {
199
+ /**
200
+ * Strip a leading `--- ... ---` pair as frontmatter (default true).
201
+ * Pass false for non-markdown plain-text sources (.yaml/.json/.txt/…)
202
+ * where the delimiters are real content — a multi-document YAML starts
203
+ * with them (see shouldStripFrontmatter in @plannotator/core/annotatable).
204
+ */
205
+ frontmatter?: boolean;
206
+ }
207
+
208
+ // CommonMark bounds a link label to 999 characters. Reusing that bound here
209
+ // also caps the worst-case backtracking cost of the bracket-matching groups
210
+ // below to a constant per starting position, turning a document with a very
211
+ // long run of unmatched `[` characters (a real hazard within the 2MB annotate
212
+ // cap) into a linear scan instead of a quadratic one. A label longer than
213
+ // this is a deliberate, documented degradation: it is neither collected as a
214
+ // definition nor resolved as a reference, so it is simply left untouched
215
+ // rather than partially or incorrectly rewritten.
216
+ const MAX_REF_LABEL_CHARS = 999;
217
+ // Same reasoning applied to the inline-code-span alternative: bounding how far
218
+ // a lazy scan for a closing backtick run can travel keeps a line with many
219
+ // stray, unterminated backticks linear too. 5000 is far beyond any realistic
220
+ // inline code span, so legitimate spans are unaffected.
221
+ const MAX_CODE_SPAN_CHARS = 5000;
222
+ // Defense-in-depth cap on the number of definitions collected from a single
223
+ // document. A pathological document could otherwise grow the map without
224
+ // bound; this keeps that growth bounded even though ordinary documents never
225
+ // approach it.
226
+ const MAX_TRACKED_DEFINITIONS = 20_000;
227
+
228
+ // A link reference definition: `[label]: destination "optional title"`, with up
229
+ // to three leading spaces. The destination is a bare token or an <...> form; any
230
+ // trailing text must be a quoted or parenthesized title, otherwise the line is
231
+ // ordinary prose (so `[Reminder]: call the bank` is NOT a definition). Matches
232
+ // the CommonMark shape closely enough for the simplified parser. `\r?` before
233
+ // the end anchor tolerates a CRLF source (lines are split on `\n` only, so a
234
+ // CRLF line keeps its trailing `\r`).
235
+ const REFERENCE_DEFINITION_RE = new RegExp(
236
+ `^ {0,3}\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\]:[ \\t]*(?:<([^>]*)>|(\\S+))[ \\t]*(?:"[^"]*"|'[^']*'|\\([^)]*\\))?[ \\t]*\\r?$`,
237
+ );
238
+
239
+ // One left-to-right pass over a line. The first alternative matches a whole
240
+ // inline code span (balanced backtick run) so its contents are skipped; the
241
+ // second matches a reference link/image: optional `!`, the bracketed text, then
242
+ // an optional second bracket for the full (`[label]`) or collapsed (`[]`) forms.
243
+ // A bare `[text]` is the shortcut form, resolved only when it names a definition
244
+ // and is not actually an inline link. Groups: 1 code ticks, 2 `!`, 3 text,
245
+ // 4 second bracket, 5 label.
246
+ const REFERENCE_LINK_RE = new RegExp(
247
+ `(\`+)[^\\n]{0,${MAX_CODE_SPAN_CHARS}}?\\1|(!?)\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\](\\[([^\\]]{0,${MAX_REF_LABEL_CHARS}})\\])?`,
248
+ 'g',
249
+ );
250
+
251
+ // CommonMark label matching is case-insensitive and collapses internal runs of
252
+ // whitespace.
253
+ const normalizeRefLabel = (label: string): string =>
254
+ label.trim().replace(/\s+/g, ' ').toLowerCase();
255
+
256
+ /**
257
+ * One pass over the lines that marks every line the block parser (below) will
258
+ * render as code or raw HTML — fenced code blocks and HTML blocks — so link
259
+ * reference definitions and references inside them are left completely
260
+ * untouched. This reuses the exact same conditions the block parser itself
261
+ * uses (not a looser approximation), so the two can never disagree about
262
+ * where code/HTML starts and ends:
263
+ *
264
+ * - Fences: `trimmed.startsWith('```')` after a full `.trim()` — the block
265
+ * parser has no minimum-indent exemption, so ANY indentation (a fence
266
+ * nested inside a list item, or simply indented 4+ spaces) still opens a
267
+ * code block, and this must too. Only backtick fences are recognized —
268
+ * the block parser has no `~~~` support, so this doesn't either (a `~~~`
269
+ * line is ordinary text to both).
270
+ * - Raw HTML blocks: the same `HTML_BLOCK_OPEN_RE`/`HTML_BLOCK_TAGS`/
271
+ * `VOID_HTML_TAGS` the block parser uses, with the same three extents
272
+ * (blank-line termination for a leading close tag, single-line for void
273
+ * tags, balanced-depth scanning otherwise) — so a definition sitting
274
+ * inside `<details>…</details>` or `<pre>…</pre>` is protected exactly as
275
+ * far as the block parser's own HTML block extends.
276
+ */
277
+ /**
278
+ * Per-tag-name index backing `findHtmlBlockEnd`. `augmented` is the running
279
+ * open-tag-count-minus-close-tag-count prefix sum for this tag name, with a
280
+ * virtual baseline of 0 prepended at index 0 — so `augmented[k]` is the sum
281
+ * through line `k-1` (the depth baseline a block opening at line `k` must
282
+ * return to) and `augmented[k+1]` is the sum through line `k`.
283
+ * `nextAtOrBelow[m]` is the classic "next element at or below this one"
284
+ * index over `augmented`: the smallest `m' > m` with `augmented[m'] <=
285
+ * augmented[m]`, or -1 if none exists.
286
+ */
287
+ interface TagCloseIndex {
288
+ augmented: number[];
289
+ nextAtOrBelow: number[];
290
+ }
291
+
292
+ /**
293
+ * Builds a `TagCloseIndex` for one tag name in a single O(N) pass (plus a
294
+ * classic O(N) monotonic-stack pass for `nextAtOrBelow` — each index is
295
+ * pushed and popped at most once, so the two passes together are linear in
296
+ * the document's line count, independent of how many opening/closing tags
297
+ * it contains).
298
+ */
299
+ function buildTagCloseIndex(lines: string[], tagName: string): TagCloseIndex {
300
+ const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
301
+ const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
302
+ const n = lines.length;
303
+ const augmented = new Array<number>(n + 1);
304
+ augmented[0] = 0;
305
+ let running = 0;
306
+ for (let k = 0; k < n; k++) {
307
+ running += (lines[k].match(openRe) || []).length;
308
+ running -= (lines[k].match(closeRe) || []).length;
309
+ augmented[k + 1] = running;
310
+ }
311
+ const nextAtOrBelow = new Array<number>(n + 1).fill(-1);
312
+ const stack: number[] = [];
313
+ for (let m = n; m >= 0; m--) {
314
+ while (stack.length && augmented[stack[stack.length - 1]] > augmented[m]) stack.pop();
315
+ nextAtOrBelow[m] = stack.length ? stack[stack.length - 1] : -1;
316
+ stack.push(m);
317
+ }
318
+ return { augmented, nextAtOrBelow };
319
+ }
320
+
321
+ /**
322
+ * Shared helper computing the last line index of a balanced open/close-tag
323
+ * HTML block that opens at `startIndex` with the given already-computed
324
+ * `depth` (the opening line's own open-tag count minus close-tag count).
325
+ * Used by both `markProtectedLines` (the resolver's protection pass) and
326
+ * `parseMarkdownToBlocks` (the block parser) so the two can never disagree
327
+ * about a multi-line HTML block's extent, and so a fix here lives in exactly
328
+ * one place instead of two copies drifting apart.
329
+ *
330
+ * History: naively scanning line-by-line from `startIndex` until depth
331
+ * returns to zero (or giving up at end-of-document) is O(N^2) for a
332
+ * document with many consecutive unclosed openers (e.g. thousands of bare
333
+ * `<div>` lines), since every one of them re-scans to EOF. A first fix
334
+ * added an O(1) "does a close exist anywhere" pre-check plus a fixed
335
+ * line-count cap on the residual scan — but that cap silently truncated
336
+ * VALID blocks longer than it, and removing the cap alone reopened a
337
+ * closely related O(N^2) case: N unclosed openers followed by a SINGLE
338
+ * trailing close still all pass the "a close exists somewhere" pre-check,
339
+ * so every one of them still scans forward (mostly to EOF) before giving up.
340
+ *
341
+ * Fixed properly here with a per-tag-name prefix-sum index
342
+ * (`buildTagCloseIndex`, O(N), built once per tag name and cached per
343
+ * document — see `closeCache`): finding "the exact line where a block
344
+ * starting at `startIndex` closes, if ever" is exactly the classic "next
345
+ * smaller-or-equal element" query against that prefix sum, which the index
346
+ * answers in O(1). No scanning happens per opener at all — not for a block
347
+ * that never closes, not for one that closes after any number of
348
+ * intervening lines, however many. This is provably linear overall (a
349
+ * document with T distinct protected tag names costs O(T * N) to index,
350
+ * and T is bounded by the small, fixed `HTML_BLOCK_TAGS` set) and can never
351
+ * truncate a valid block, because it always finds the block's real end
352
+ * (however far away) rather than giving up at a fixed distance.
353
+ *
354
+ * Returns `startIndex` unchanged when the block never closes: depth <= 0,
355
+ * or the running depth never returns to exactly zero anywhere in the rest
356
+ * of the document (whether because no close exists at all, or one exists
357
+ * but is insufficient to bring the count back to exactly the opener's own
358
+ * baseline — e.g. an unbalanced/self-closing tag).
359
+ */
360
+ function findHtmlBlockEnd(
361
+ lines: string[],
362
+ startIndex: number,
363
+ tagName: string,
364
+ depth: number,
365
+ closeCache: Map<string, TagCloseIndex>,
366
+ ): number {
367
+ if (depth <= 0) return startIndex;
368
+ let index = closeCache.get(tagName);
369
+ if (!index) {
370
+ index = buildTagCloseIndex(lines, tagName);
371
+ closeCache.set(tagName, index);
372
+ }
373
+ const { augmented, nextAtOrBelow } = index;
374
+ const m = nextAtOrBelow[startIndex];
375
+ if (m === -1) return startIndex;
376
+ return augmented[m] === augmented[startIndex] ? m - 1 : startIndex;
377
+ }
378
+
379
+ const markProtectedLines = (lines: string[]): boolean[] => {
380
+ const isProtected = new Array<boolean>(lines.length).fill(false);
381
+ let fenceLen = 0; // 0 = not currently inside a fence
382
+ const closeCache = new Map<string, TagCloseIndex>();
383
+ for (let i = 0; i < lines.length; i++) {
384
+ if (fenceLen > 0) {
385
+ isProtected[i] = true;
386
+ if (new RegExp('^\\s*`{' + fenceLen + ',}').test(lines[i])) fenceLen = 0;
387
+ continue;
388
+ }
389
+ const trimmed = lines[i].trim();
390
+ if (trimmed.startsWith('```')) {
391
+ fenceLen = trimmed.match(/^`+/)![0].length;
392
+ isProtected[i] = true;
393
+ continue;
394
+ }
395
+ const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE);
396
+ if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) {
397
+ const tagName = htmlTagMatch[1].toLowerCase();
398
+ const isCloseTag = trimmed.startsWith('</');
399
+ isProtected[i] = true;
400
+ if (isCloseTag) {
401
+ while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
402
+ i++;
403
+ isProtected[i] = true;
404
+ }
405
+ } else if (VOID_HTML_TAGS.has(tagName)) {
406
+ while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
407
+ i++;
408
+ isProtected[i] = true;
409
+ }
410
+ } else {
411
+ const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
412
+ const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
413
+ const depth = (lines[i].match(openRe) || []).length - (lines[i].match(closeRe) || []).length;
414
+ const end = findHtmlBlockEnd(lines, i, tagName, depth, closeCache);
415
+ if (end > i) {
416
+ for (let idx = i + 1; idx <= end; idx++) isProtected[idx] = true;
417
+ i = end;
418
+ }
419
+ }
420
+ }
421
+ }
422
+ return isProtected;
423
+ };
424
+
425
+ /** Resolve reference links/images in one non-code, non-HTML line. A single
426
+ * left-to-right pass: an inline code span is matched as a whole and returned
427
+ * verbatim, so a reference-looking pattern inside backticks is never
428
+ * rewritten; only bracketed references outside code are resolved. Every label
429
+ * that actually resolves against a definition is recorded into `usedLabels`,
430
+ * so the caller can tell a genuinely consumed definition from an unused one. */
431
+ const resolveRefsInLine = (
432
+ line: string,
433
+ defs: Map<string, string>,
434
+ usedLabels: Set<string>,
435
+ ): string => {
436
+ if (!line.includes('[')) return line;
437
+ return line.replace(
438
+ REFERENCE_LINK_RE,
439
+ (match, codeTicks, bang, text, secondBracket, label, offset: number, whole: string) => {
440
+ if (codeTicks !== undefined) return match; // inline code span: keep verbatim
441
+ let refLabel: string;
442
+ if (secondBracket === undefined) {
443
+ // Shortcut `[text]`: not a link when an inline `(...)` destination
444
+ // follows (that is an inline link the existing renderer already draws).
445
+ if (whole[offset + match.length] === '(') return match;
446
+ // Nor when it is a task-list checkbox marker at the start of a list
447
+ // item (`- [x]`); the checkbox parser owns that `[x]`, and resolving it
448
+ // against a stray `x`/`X` definition would clobber the item.
449
+ if (/^[ xX]$/.test(text) && /^\s*(?:[-*+]|\d+[.)])\s+$/.test(whole.slice(0, offset))) {
450
+ return match;
451
+ }
452
+ refLabel = text;
453
+ } else {
454
+ refLabel = label === '' ? text : label;
455
+ }
456
+ const normalized = normalizeRefLabel(refLabel);
457
+ const dest = defs.get(normalized);
458
+ // An unknown reference stays literal, matching CommonMark and avoiding
459
+ // false links for bracketed prose like `[TODO]` or array indices.
460
+ if (!dest) return match;
461
+ usedLabels.add(normalized);
462
+ return `${bang}[${text}](${dest})`;
463
+ },
464
+ );
465
+ };
466
+
467
+ /**
468
+ * Resolve CommonMark link reference definitions and reference links into inline
469
+ * `[text](url)` links, so the shared inline renderer draws them instead of
470
+ * showing raw `[text][id]` and `[id]: url` text (issue #923). Definitions and
471
+ * references inside fenced code blocks, raw HTML blocks, and inline code spans
472
+ * are left untouched. A definition-shaped line is only ever blanked when its
473
+ * label was actually consumed by a resolved reference outside a protected
474
+ * region — an unused definition, or one referenced only from inside code/HTML,
475
+ * stays visible exactly as written. Blanked lines keep block start-line
476
+ * numbers accurate (and their own CRLF ending, so line endings round-trip).
477
+ * GFM footnote definitions (`[^label]: ...`) are never treated as link
478
+ * definitions. No-op (returns the input) when the document defines no
479
+ * (non-footnote) references.
480
+ */
481
+ export const resolveReferenceLinks = (markdown: string): string => {
482
+ if (!markdown.includes('[')) return markdown;
483
+ const lines = markdown.split('\n');
484
+ const isProtected = markProtectedLines(lines);
485
+ const defs = new Map<string, string>();
486
+ // The normalized label a definition-shaped line defines, or null if the
487
+ // line isn't a definition (or is a footnote definition, which is never
488
+ // collected/blanked).
489
+ const defLabelByLine = new Array<string | null>(lines.length).fill(null);
490
+ // A definition cannot interrupt a paragraph (CommonMark 4.7): a line matching
491
+ // the definition shape is only a definition when it can start a block, i.e.
492
+ // the previous line is the document start, blank, a protected code/HTML
493
+ // line (each is its own block), or itself a definition. Otherwise the line
494
+ // is paragraph continuation text and must be left untouched, or a bare
495
+ // `[word]: token` under a sentence would be silently deleted.
496
+ let canStartDefinition = true;
497
+ for (let i = 0; i < lines.length; i++) {
498
+ if (isProtected[i]) {
499
+ canStartDefinition = true;
500
+ continue;
501
+ }
502
+ const blank = lines[i].trim() === '';
503
+ const match = canStartDefinition && !blank ? lines[i].match(REFERENCE_DEFINITION_RE) : null;
504
+ if (match) {
505
+ const rawLabel = match[1];
506
+ // GFM footnote definition ([^label]: ...) — not a link reference
507
+ // definition. Leave it out of `defs` entirely so it can never be
508
+ // collected, blanked, or accidentally satisfy a footnote reference's
509
+ // lookup; it stays block-starting like any other definition line.
510
+ if (!rawLabel.startsWith('^') && defs.size < MAX_TRACKED_DEFINITIONS) {
511
+ const label = normalizeRefLabel(rawLabel);
512
+ const dest = match[2] !== undefined ? match[2] : match[3];
513
+ // First definition wins, per CommonMark.
514
+ if (label && dest && !defs.has(label)) defs.set(label, dest);
515
+ defLabelByLine[i] = label;
516
+ }
517
+ // A run of definitions stays eligible; canStartDefinition remains true.
518
+ } else {
519
+ // Blank keeps a new block startable; any other non-definition line starts
520
+ // (or continues) a paragraph, so a following definition-shaped line is text.
521
+ canStartDefinition = blank;
522
+ }
523
+ }
524
+ if (defs.size === 0) return markdown;
525
+ const usedLabels = new Set<string>();
526
+ // Resolve references first; definition-shaped lines are passed through
527
+ // unresolved (never fed to resolveRefsInLine) so a definition's own
528
+ // `[label]` can never be mistaken for a reference to itself.
529
+ const resolved = lines.map((line, i) =>
530
+ isProtected[i] || defLabelByLine[i] !== null ? line : resolveRefsInLine(line, defs, usedLabels),
531
+ );
532
+ return resolved
533
+ .map((line, i) => {
534
+ const label = defLabelByLine[i];
535
+ if (label === null || !usedLabels.has(label)) return line;
536
+ // Blank in place, preserving this line's own CRLF ending if it had one.
537
+ return line.endsWith('\r') ? '\r' : '';
538
+ })
539
+ .join('\n');
540
+ };
541
+
104
542
  /**
105
543
  * A simplified markdown parser that splits content into linear blocks.
106
544
  * For a production app, we would use a robust AST walker (remark),
107
545
  * but for this demo, we want predictable text-anchoring.
108
546
  */
109
- export const parseMarkdownToBlocks = (markdown: string): Block[] => {
110
- const { content: cleanMarkdown, contentStartLine } = extractFrontmatter(markdown);
547
+ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownOptions): Block[] => {
548
+ const { content: rawContent, contentStartLine } =
549
+ options?.frontmatter === false
550
+ ? { content: markdown, contentStartLine: 1 }
551
+ : extractFrontmatter(markdown);
552
+ // Resolve link reference definitions into inline links before splitting. This
553
+ // blanks definition lines in place, so line count (and every block's
554
+ // startLine) is preserved.
555
+ const cleanMarkdown = resolveReferenceLinks(rawContent);
111
556
  const lines = cleanMarkdown.split('\n');
112
557
  const blocks: Block[] = [];
113
558
  let currentId = 0;
559
+ // Cache for findHtmlBlockEnd's per-tag-name prefix-sum index — scoped per
560
+ // parse call (per document) and shared across every HTML-block opener
561
+ // encountered below, so a document with many consecutive openers of the
562
+ // same tag only pays its one-time O(N) build cost once.
563
+ const htmlCloseCache = new Map<string, TagCloseIndex>();
114
564
 
115
565
  let buffer: string[] = [];
116
566
  let currentType: Block['type'] = 'paragraph';
@@ -501,23 +951,15 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
501
951
  const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
502
952
  const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
503
953
  const depth = (line.match(openRe) || []).length - (line.match(closeRe) || []).length;
504
- if (depth > 0) {
505
- // Scan ahead for the matching close tag. If none is ever found — a
506
- // self-closing <video/>, or an unclosed <picture>/<div> — do NOT swallow
507
- // the rest of the document into this block; keep it to the opening line.
508
- let j = i;
509
- let d = depth;
510
- const scanned: string[] = [];
511
- while (d > 0 && j + 1 < lines.length) {
512
- j++;
513
- scanned.push(lines[j]);
514
- d += (lines[j].match(openRe) || []).length;
515
- d -= (lines[j].match(closeRe) || []).length;
516
- }
517
- if (d === 0) {
518
- i = j;
519
- for (const s of scanned) htmlLines.push(s);
520
- }
954
+ // Scan ahead for the matching close tag via the shared, bounded/
955
+ // linear helper (see its doc comment for why a naive per-opener scan
956
+ // is quadratic). If none is ever found — a self-closing <video/>, or
957
+ // an unclosed <picture>/<div> — do NOT swallow the rest of the
958
+ // document into this block; keep it to the opening line.
959
+ const end = findHtmlBlockEnd(lines, i, tagName, depth, htmlCloseCache);
960
+ if (end > i) {
961
+ for (let k = i + 1; k <= end; k++) htmlLines.push(lines[k]);
962
+ i = end;
521
963
  }
522
964
  }
523
965
 
@@ -656,6 +1098,32 @@ const blockEndLine = (block: Block): number => {
656
1098
 
657
1099
  /** Resolve the source-line label for a single annotation.
658
1100
  * Returns null for global comments, diff-view annotations, or missing blocks. */
1101
+ /** Multi-target raw-HTML comments: list every ADDITIONAL element the one
1102
+ * comment covers (the primary target is already quoted as `originalText`),
1103
+ * labeled with the semantic hover label plus a short excerpt so the agent
1104
+ * reading the feedback sees every referenced element. Emits nothing for
1105
+ * single-target annotations, keeping their output byte-identical. */
1106
+ const additionalTargetsExportBlock = (ann: any): string => {
1107
+ const targets = ann?.htmlAdditionalTargets;
1108
+ if (!Array.isArray(targets) || targets.length === 0) return '';
1109
+ // Leading blank line: the preceding comment line is a `> blockquote`, and
1110
+ // markdown lazy continuation would otherwise fold this block into it.
1111
+ let block = `\n**Also applies to ${targets.length} more element${targets.length > 1 ? 's' : ''}:**\n`;
1112
+ targets.forEach((target: any) => {
1113
+ // Labels and texts are page-controlled (aria-label etc.). The DTO
1114
+ // boundary already collapses label whitespace; do it here again (defense
1115
+ // in depth) so persisted pre-fix data can never smuggle newlines — and
1116
+ // with them fake markdown structure — into agent-read feedback.
1117
+ const rawLabel = typeof target?.label === 'string' ? target.label.replace(/\s+/g, ' ').trim() : '';
1118
+ const label = rawLabel ? `[${rawLabel}] ` : '';
1119
+ const raw = typeof target?.text === 'string' ? target.text : '';
1120
+ const excerpt = raw.replace(/\s+/g, ' ').trim();
1121
+ const clipped = excerpt.length > 120 ? `${excerpt.slice(0, 120)}…` : excerpt;
1122
+ block += `- ${label}"${clipped}"\n`;
1123
+ });
1124
+ return block;
1125
+ };
1126
+
659
1127
  const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
660
1128
  if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
661
1129
  if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
@@ -686,6 +1154,10 @@ export const exportAnnotations = (
686
1154
  return a.startOffset - b.startOffset;
687
1155
  });
688
1156
 
1157
+ // One injection per export: a human-only skill referenced by several
1158
+ // comments has its instructions injected once (see skillReferenceExportBlock).
1159
+ const injectedSkills = new Set<string>();
1160
+
689
1161
  let output = `# ${title}\n\n`;
690
1162
 
691
1163
  if (opts.sourceConverted) {
@@ -742,6 +1214,17 @@ export const exportAnnotations = (
742
1214
  break;
743
1215
  }
744
1216
 
1217
+ // Multi-target raw-HTML comments list every additional covered element.
1218
+ output += additionalTargetsExportBlock(ann);
1219
+
1220
+ // Skill references in the comment text (no-op unless a catalog is
1221
+ // registered). An annotation carrying a `source` arrived through the
1222
+ // external-annotations API, not from the reviewer — it may list skills
1223
+ // but must never cause a human-only skill's instructions to be injected.
1224
+ if (!ann.isQuickLabel) {
1225
+ output += skillReferenceExportBlock(ann.text, injectedSkills, { external: !!ann.source });
1226
+ }
1227
+
745
1228
  // Add attached images for this annotation
746
1229
  if (ann.images && ann.images.length > 0) {
747
1230
  output += `**Attached images:**\n`;
@@ -786,6 +1269,9 @@ export const exportLinkedDocAnnotations = (
786
1269
  ): string => {
787
1270
  let output = `\n# Linked Document Feedback\n\nThe following feedback is on documents referenced in the plan.\n\n`;
788
1271
 
1272
+ // One injection per export, across all linked documents.
1273
+ const injectedSkills = new Set<string>();
1274
+
789
1275
  for (const [filepath, { annotations, globalAttachments, blocks: docBlocks, isConverted }] of docAnnotations) {
790
1276
  if (annotations.length === 0 && globalAttachments.length === 0) continue;
791
1277
 
@@ -832,6 +1318,12 @@ export const exportLinkedDocAnnotations = (
832
1318
  break;
833
1319
  }
834
1320
 
1321
+ // Multi-target raw-HTML comments list every additional covered element.
1322
+ output += additionalTargetsExportBlock(ann);
1323
+
1324
+ // External (tool-sourced) comments list skills but never inject.
1325
+ output += skillReferenceExportBlock(ann.text, injectedSkills, { external: !!ann.source });
1326
+
835
1327
  if (ann.images && ann.images.length > 0) {
836
1328
  output += `**Attached images:**\n`;
837
1329
  ann.images.forEach((img: ImageAttachment) => {
@@ -875,6 +1367,8 @@ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string
875
1367
  if (annotations.length === 0) return '';
876
1368
 
877
1369
  let output = `\n# Code File Feedback\n\nThe following feedback is on code files referenced from the reviewed document.\n\n`;
1370
+ // One injection per export, across all code-file comments.
1371
+ const injectedSkills = new Set<string>();
878
1372
  const sorted = [...annotations].sort((a, b) => {
879
1373
  if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath);
880
1374
  if (a.lineStart !== b.lineStart) return a.lineStart - b.lineStart;
@@ -893,6 +1387,8 @@ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string
893
1387
  if (ann.text) {
894
1388
  output += `> ${ann.text}\n`;
895
1389
  }
1390
+ // External (tool-sourced) comments list skills but never inject.
1391
+ output += skillReferenceExportBlock(ann.text, injectedSkills, { external: !!ann.source });
896
1392
  if (ann.images && ann.images.length > 0) {
897
1393
  output += `**Attached images:**\n`;
898
1394
  ann.images.forEach((img) => {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Staleness window for raw-HTML session preferences (input method, chrome
3
+ * visibility, drawer state). An explicit choice sticks while the user is
4
+ * actively annotating HTML; after this long without a refresh the preference
5
+ * expires and the session opens on the product defaults again (Pinpoint,
6
+ * tools hidden, drawer closed). Timestamps refresh on explicit changes and on
7
+ * annotation activity, so regulars keep their setup and lapsed sessions reset.
8
+ */
9
+ export const STALE_PREFERENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
10
+
11
+ /** True when a persisted `savedAt` is missing, malformed, or older than the TTL. */
12
+ export function isStalePreference(savedAt: unknown, now: number): boolean {
13
+ if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) return true;
14
+ return now - savedAt > STALE_PREFERENCE_TTL_MS;
15
+ }
package/utils/sharing.ts CHANGED
@@ -298,7 +298,6 @@ export async function createShortShareUrl(
298
298
  }
299
299
  // Service unavailable — expected for self-hosted setups without a paste backend.
300
300
  // The caller is responsible for falling back to hash-based sharing silently.
301
- console.debug('[sharing] Short URL service unavailable, using hash-based sharing:', e);
302
301
  return null;
303
302
  }
304
303
  }