@plannotator/ui 0.47.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/HANDOFF.md +147 -0
  2. package/LICENSE +191 -0
  3. package/README.md +56 -1
  4. package/components/AISettingsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +44 -3
  6. package/components/BlockRenderer.tsx +55 -1
  7. package/components/CompletionOverlay.tsx +23 -1
  8. package/components/DiffFileTree.tsx +363 -0
  9. package/components/ImageLightbox.tsx +66 -0
  10. package/components/PRPlatformIcon.tsx +22 -0
  11. package/components/ProviderIcons.tsx +15 -3
  12. package/components/QuestionProgressChip.tsx +66 -0
  13. package/components/QuestionsPanelSection.tsx +121 -0
  14. package/components/Settings.tsx +25 -1
  15. package/components/Viewer.tsx +170 -34
  16. package/components/ai/AIProviderBar.tsx +5 -5
  17. package/components/ai/DocumentAIChatPanel.tsx +28 -3
  18. package/components/ai/SessionAskNotice.tsx +89 -0
  19. package/components/blocks/QuestionBlock.tsx +591 -0
  20. package/components/html-viewer/bridge-script.asset.js +85 -8
  21. package/components/html-viewer/bridge-script.ts +85 -8
  22. package/config/settings.ts +17 -0
  23. package/configure.ts +12 -0
  24. package/hooks/useAIChat.ts +74 -9
  25. package/hooks/useAIProviderConfig.ts +32 -14
  26. package/hooks/useAnnotationHighlighter.ts +4 -0
  27. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  28. package/hooks/usePinpoint.ts +10 -0
  29. package/hooks/useScrollKeyRouting.ts +157 -0
  30. package/hooks/useUndoHistory.ts +17 -0
  31. package/hooks/useVimDocumentFocus.ts +2 -1
  32. package/package.json +3 -2
  33. package/styles.css +1 -1
  34. package/types.ts +7 -0
  35. package/utils/aiPrompt.ts +31 -0
  36. package/utils/aiProvider.ts +68 -0
  37. package/utils/autoUpdateNotice.ts +59 -0
  38. package/utils/diffFileTree.ts +203 -0
  39. package/utils/htmlLinkNavigation.ts +87 -3
  40. package/utils/parser.ts +125 -563
  41. package/utils/questionAnswers.ts +227 -0
  42. package/utils/syntaxTheme.ts +33 -0
package/utils/parser.ts CHANGED
@@ -2,8 +2,32 @@ import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachme
2
2
  import { planDenyFeedback } from '@plannotator/core/feedback-templates';
3
3
  import { resolveReplyParents } from '@plannotator/core/annotation-threads';
4
4
  import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor';
5
+ import {
6
+ formatQuestionAnswerLines,
7
+ formatQuestionAnswersSection,
8
+ indexQuestionBlocks,
9
+ parseQuestionAnswer,
10
+ questionExportItems,
11
+ type QuestionAnswer,
12
+ type QuestionExportItem,
13
+ } from '@plannotator/core/question-block';
14
+ import {
15
+ DIRECTIVE_OPEN_RE,
16
+ HTML_BLOCK_TAGS,
17
+ codeFenceCloseIndex,
18
+ directiveCloseIndex,
19
+ htmlBlockEndAt,
20
+ resolveReferenceLinks,
21
+ scanDisplayMath,
22
+ splitFrontmatter,
23
+ type TagCloseIndex,
24
+ } from '@plannotator/core/markdown-structure';
5
25
  import { skillReferenceExportBlock } from './skillReferences';
6
26
 
27
+ // The structural helpers moved to `@plannotator/core/markdown-structure` so
28
+ // `findQuestionBlocks` (core) splits a document exactly as this parser does.
29
+ export { HTML_BLOCK_TAGS, resolveReferenceLinks };
30
+
7
31
  /**
8
32
  * Parsed YAML frontmatter value: scalar string, array, or nested map.
9
33
  */
@@ -128,30 +152,11 @@ function parseKeyValue(str: string): { key: string; value: string } | null {
128
152
  * line references stay accurate).
129
153
  */
130
154
  export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter | null; content: string; contentStartLine: number } {
131
- const trimmed = markdown.trimStart();
132
- if (!trimmed.startsWith('---')) {
155
+ const { raw: frontmatterRaw, content: afterFrontmatter, contentStartLine } = splitFrontmatter(markdown);
156
+ if (frontmatterRaw === null) {
133
157
  return { frontmatter: null, content: markdown, contentStartLine: 1 };
134
158
  }
135
159
 
136
- // Find the closing ---
137
- const endIndex = trimmed.indexOf('\n---', 3);
138
- if (endIndex === -1) {
139
- return { frontmatter: null, content: markdown, contentStartLine: 1 };
140
- }
141
-
142
- // Extract frontmatter content (between the --- delimiters)
143
- const frontmatterRaw = trimmed.slice(4, endIndex).trim();
144
- const rawAfterFrontmatter = trimmed.slice(endIndex + 4);
145
- const afterFrontmatter = rawAfterFrontmatter.trimStart();
146
-
147
- // Compute the 1-based line where content begins in the original file.
148
- // Account for: leading whitespace trimmed from original, the frontmatter
149
- // block itself, and any blank lines between closing --- and first content.
150
- const leadingChars = markdown.length - trimmed.length;
151
- const consumedInTrimmed = endIndex + 4 + (rawAfterFrontmatter.length - afterFrontmatter.length);
152
- const consumedTotal = leadingChars + consumedInTrimmed;
153
- const contentStartLine = (markdown.slice(0, consumedTotal).match(/\n/g) || []).length + 1;
154
-
155
160
  // Parse simple YAML (key: value pairs, indentation-aware)
156
161
  const frontmatter: Frontmatter = {};
157
162
  const mapStack: { indent: number; map: { [key: string]: FrontmatterValue } }[] = [
@@ -280,32 +285,6 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter
280
285
  return { frontmatter, content: afterFrontmatter, contentStartLine };
281
286
  }
282
287
 
283
- /**
284
- * Tag names that trigger a raw HTML block per CommonMark §4.6, Type 6.
285
- * A line starting with `<tag` or `</tag` (where `tag` is in this set) opens
286
- * an HTML block that continues verbatim until a blank line or EOF.
287
- *
288
- * Inline-only tags (`kbd`, `sub`, `sup`, `mark`, etc.) are NOT here — a line
289
- * that happens to start with one of those still goes through the paragraph
290
- * path and renders as escaped text, matching prior behavior.
291
- */
292
- export const HTML_BLOCK_TAGS: ReadonlySet<string> = new Set([
293
- 'details', 'summary',
294
- 'div', 'section', 'article', 'aside', 'header', 'footer',
295
- 'blockquote', 'pre',
296
- 'table', 'thead', 'tbody', 'tr', 'td', 'th',
297
- 'ul', 'ol', 'li', 'p',
298
- // Media: GitHub embeds screenshots/videos as raw HTML on their own line.
299
- 'img', 'video', 'picture',
300
- ]);
301
-
302
- /** Void elements — no closing tag, so the block is a single line (don't scan
303
- * ahead for a `</tag>` that will never come). */
304
- const VOID_HTML_TAGS: ReadonlySet<string> = new Set([
305
- 'img', 'br', 'hr', 'source', 'input', 'wbr', 'area', 'col', 'embed',
306
- ]);
307
-
308
- const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s|>|\/|$)/;
309
288
 
310
289
  export interface ParseMarkdownOptions {
311
290
  /**
@@ -317,339 +296,6 @@ export interface ParseMarkdownOptions {
317
296
  frontmatter?: boolean;
318
297
  }
319
298
 
320
- // CommonMark bounds a link label to 999 characters. Reusing that bound here
321
- // also caps the worst-case backtracking cost of the bracket-matching groups
322
- // below to a constant per starting position, turning a document with a very
323
- // long run of unmatched `[` characters (a real hazard within the 2MB annotate
324
- // cap) into a linear scan instead of a quadratic one. A label longer than
325
- // this is a deliberate, documented degradation: it is neither collected as a
326
- // definition nor resolved as a reference, so it is simply left untouched
327
- // rather than partially or incorrectly rewritten.
328
- const MAX_REF_LABEL_CHARS = 999;
329
- // Same reasoning applied to the inline-code-span alternative: bounding how far
330
- // a lazy scan for a closing backtick run can travel keeps a line with many
331
- // stray, unterminated backticks linear too. 5000 is far beyond any realistic
332
- // inline code span, so legitimate spans are unaffected.
333
- const MAX_CODE_SPAN_CHARS = 5000;
334
- // Defense-in-depth cap on the number of definitions collected from a single
335
- // document. A pathological document could otherwise grow the map without
336
- // bound; this keeps that growth bounded even though ordinary documents never
337
- // approach it.
338
- const MAX_TRACKED_DEFINITIONS = 20_000;
339
-
340
- // A link reference definition: `[label]: destination "optional title"`, with up
341
- // to three leading spaces. The destination is a bare token or an <...> form; any
342
- // trailing text must be a quoted or parenthesized title, otherwise the line is
343
- // ordinary prose (so `[Reminder]: call the bank` is NOT a definition). Matches
344
- // the CommonMark shape closely enough for the simplified parser. `\r?` before
345
- // the end anchor tolerates a CRLF source (lines are split on `\n` only, so a
346
- // CRLF line keeps its trailing `\r`).
347
- const REFERENCE_DEFINITION_RE = new RegExp(
348
- `^ {0,3}\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\]:[ \\t]*(?:<([^>]*)>|(\\S+))[ \\t]*(?:"[^"]*"|'[^']*'|\\([^)]*\\))?[ \\t]*\\r?$`,
349
- );
350
-
351
- // One left-to-right pass over a line. The first alternative matches a whole
352
- // inline code span (balanced backtick run) so its contents are skipped; the
353
- // second matches a reference link/image: optional `!`, the bracketed text, then
354
- // an optional second bracket for the full (`[label]`) or collapsed (`[]`) forms.
355
- // A bare `[text]` is the shortcut form, resolved only when it names a definition
356
- // and is not actually an inline link. Groups: 1 code ticks, 2 `!`, 3 text,
357
- // 4 second bracket, 5 label.
358
- const REFERENCE_LINK_RE = new RegExp(
359
- `(\`+)[^\\n]{0,${MAX_CODE_SPAN_CHARS}}?\\1|(!?)\\[([^\\]]{1,${MAX_REF_LABEL_CHARS}})\\](\\[([^\\]]{0,${MAX_REF_LABEL_CHARS}})\\])?`,
360
- 'g',
361
- );
362
-
363
- // CommonMark label matching is case-insensitive and collapses internal runs of
364
- // whitespace.
365
- const normalizeRefLabel = (label: string): string =>
366
- label.trim().replace(/\s+/g, ' ').toLowerCase();
367
-
368
- /**
369
- * One pass over the lines that marks every line the block parser (below) will
370
- * render as code or raw HTML — fenced code blocks and HTML blocks — so link
371
- * reference definitions and references inside them are left completely
372
- * untouched. This reuses the exact same conditions the block parser itself
373
- * uses (not a looser approximation), so the two can never disagree about
374
- * where code/HTML starts and ends:
375
- *
376
- * - Fences: `trimmed.startsWith('```')` after a full `.trim()` — the block
377
- * parser has no minimum-indent exemption, so ANY indentation (a fence
378
- * nested inside a list item, or simply indented 4+ spaces) still opens a
379
- * code block, and this must too. Only backtick fences are recognized —
380
- * the block parser has no `~~~` support, so this doesn't either (a `~~~`
381
- * line is ordinary text to both).
382
- * - Raw HTML blocks: the same `HTML_BLOCK_OPEN_RE`/`HTML_BLOCK_TAGS`/
383
- * `VOID_HTML_TAGS` the block parser uses, with the same three extents
384
- * (blank-line termination for a leading close tag, single-line for void
385
- * tags, balanced-depth scanning otherwise) — so a definition sitting
386
- * inside `<details>…</details>` or `<pre>…</pre>` is protected exactly as
387
- * far as the block parser's own HTML block extends.
388
- */
389
- /**
390
- * Per-tag-name index backing `findHtmlBlockEnd`. `augmented` is the running
391
- * open-tag-count-minus-close-tag-count prefix sum for this tag name, with a
392
- * virtual baseline of 0 prepended at index 0 — so `augmented[k]` is the sum
393
- * through line `k-1` (the depth baseline a block opening at line `k` must
394
- * return to) and `augmented[k+1]` is the sum through line `k`.
395
- * `nextAtOrBelow[m]` is the classic "next element at or below this one"
396
- * index over `augmented`: the smallest `m' > m` with `augmented[m'] <=
397
- * augmented[m]`, or -1 if none exists.
398
- */
399
- interface TagCloseIndex {
400
- augmented: number[];
401
- nextAtOrBelow: number[];
402
- }
403
-
404
- /**
405
- * Builds a `TagCloseIndex` for one tag name in a single O(N) pass (plus a
406
- * classic O(N) monotonic-stack pass for `nextAtOrBelow` — each index is
407
- * pushed and popped at most once, so the two passes together are linear in
408
- * the document's line count, independent of how many opening/closing tags
409
- * it contains).
410
- */
411
- function buildTagCloseIndex(lines: string[], tagName: string): TagCloseIndex {
412
- const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
413
- const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
414
- const n = lines.length;
415
- const augmented = new Array<number>(n + 1);
416
- augmented[0] = 0;
417
- let running = 0;
418
- for (let k = 0; k < n; k++) {
419
- running += (lines[k].match(openRe) || []).length;
420
- running -= (lines[k].match(closeRe) || []).length;
421
- augmented[k + 1] = running;
422
- }
423
- const nextAtOrBelow = new Array<number>(n + 1).fill(-1);
424
- const stack: number[] = [];
425
- for (let m = n; m >= 0; m--) {
426
- while (stack.length && augmented[stack[stack.length - 1]] > augmented[m]) stack.pop();
427
- nextAtOrBelow[m] = stack.length ? stack[stack.length - 1] : -1;
428
- stack.push(m);
429
- }
430
- return { augmented, nextAtOrBelow };
431
- }
432
-
433
- /**
434
- * Shared helper computing the last line index of a balanced open/close-tag
435
- * HTML block that opens at `startIndex` with the given already-computed
436
- * `depth` (the opening line's own open-tag count minus close-tag count).
437
- * Used by both `markProtectedLines` (the resolver's protection pass) and
438
- * `parseMarkdownToBlocks` (the block parser) so the two can never disagree
439
- * about a multi-line HTML block's extent, and so a fix here lives in exactly
440
- * one place instead of two copies drifting apart.
441
- *
442
- * History: naively scanning line-by-line from `startIndex` until depth
443
- * returns to zero (or giving up at end-of-document) is O(N^2) for a
444
- * document with many consecutive unclosed openers (e.g. thousands of bare
445
- * `<div>` lines), since every one of them re-scans to EOF. A first fix
446
- * added an O(1) "does a close exist anywhere" pre-check plus a fixed
447
- * line-count cap on the residual scan — but that cap silently truncated
448
- * VALID blocks longer than it, and removing the cap alone reopened a
449
- * closely related O(N^2) case: N unclosed openers followed by a SINGLE
450
- * trailing close still all pass the "a close exists somewhere" pre-check,
451
- * so every one of them still scans forward (mostly to EOF) before giving up.
452
- *
453
- * Fixed properly here with a per-tag-name prefix-sum index
454
- * (`buildTagCloseIndex`, O(N), built once per tag name and cached per
455
- * document — see `closeCache`): finding "the exact line where a block
456
- * starting at `startIndex` closes, if ever" is exactly the classic "next
457
- * smaller-or-equal element" query against that prefix sum, which the index
458
- * answers in O(1). No scanning happens per opener at all — not for a block
459
- * that never closes, not for one that closes after any number of
460
- * intervening lines, however many. This is provably linear overall (a
461
- * document with T distinct protected tag names costs O(T * N) to index,
462
- * and T is bounded by the small, fixed `HTML_BLOCK_TAGS` set) and can never
463
- * truncate a valid block, because it always finds the block's real end
464
- * (however far away) rather than giving up at a fixed distance.
465
- *
466
- * Returns `startIndex` unchanged when the block never closes: depth <= 0,
467
- * or the running depth never returns to exactly zero anywhere in the rest
468
- * of the document (whether because no close exists at all, or one exists
469
- * but is insufficient to bring the count back to exactly the opener's own
470
- * baseline — e.g. an unbalanced/self-closing tag).
471
- */
472
- function findHtmlBlockEnd(
473
- lines: string[],
474
- startIndex: number,
475
- tagName: string,
476
- depth: number,
477
- closeCache: Map<string, TagCloseIndex>,
478
- ): number {
479
- if (depth <= 0) return startIndex;
480
- let index = closeCache.get(tagName);
481
- if (!index) {
482
- index = buildTagCloseIndex(lines, tagName);
483
- closeCache.set(tagName, index);
484
- }
485
- const { augmented, nextAtOrBelow } = index;
486
- const m = nextAtOrBelow[startIndex];
487
- if (m === -1) return startIndex;
488
- return augmented[m] === augmented[startIndex] ? m - 1 : startIndex;
489
- }
490
-
491
- const markProtectedLines = (lines: string[]): boolean[] => {
492
- const isProtected = new Array<boolean>(lines.length).fill(false);
493
- let fenceLen = 0; // 0 = not currently inside a fence
494
- const closeCache = new Map<string, TagCloseIndex>();
495
- for (let i = 0; i < lines.length; i++) {
496
- if (fenceLen > 0) {
497
- isProtected[i] = true;
498
- if (new RegExp('^\\s*`{' + fenceLen + ',}').test(lines[i])) fenceLen = 0;
499
- continue;
500
- }
501
- const trimmed = lines[i].trim();
502
- if (trimmed.startsWith('```')) {
503
- fenceLen = trimmed.match(/^`+/)![0].length;
504
- isProtected[i] = true;
505
- continue;
506
- }
507
- const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE);
508
- if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) {
509
- const tagName = htmlTagMatch[1].toLowerCase();
510
- const isCloseTag = trimmed.startsWith('</');
511
- isProtected[i] = true;
512
- if (isCloseTag) {
513
- while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
514
- i++;
515
- isProtected[i] = true;
516
- }
517
- } else if (VOID_HTML_TAGS.has(tagName)) {
518
- while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
519
- i++;
520
- isProtected[i] = true;
521
- }
522
- } else {
523
- const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
524
- const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
525
- const depth = (lines[i].match(openRe) || []).length - (lines[i].match(closeRe) || []).length;
526
- const end = findHtmlBlockEnd(lines, i, tagName, depth, closeCache);
527
- if (end > i) {
528
- for (let idx = i + 1; idx <= end; idx++) isProtected[idx] = true;
529
- i = end;
530
- }
531
- }
532
- }
533
- }
534
- return isProtected;
535
- };
536
-
537
- /** Resolve reference links/images in one non-code, non-HTML line. A single
538
- * left-to-right pass: an inline code span is matched as a whole and returned
539
- * verbatim, so a reference-looking pattern inside backticks is never
540
- * rewritten; only bracketed references outside code are resolved. Every label
541
- * that actually resolves against a definition is recorded into `usedLabels`,
542
- * so the caller can tell a genuinely consumed definition from an unused one. */
543
- const resolveRefsInLine = (
544
- line: string,
545
- defs: Map<string, string>,
546
- usedLabels: Set<string>,
547
- ): string => {
548
- if (!line.includes('[')) return line;
549
- return line.replace(
550
- REFERENCE_LINK_RE,
551
- (match, codeTicks, bang, text, secondBracket, label, offset: number, whole: string) => {
552
- if (codeTicks !== undefined) return match; // inline code span: keep verbatim
553
- let refLabel: string;
554
- if (secondBracket === undefined) {
555
- // Shortcut `[text]`: not a link when an inline `(...)` destination
556
- // follows (that is an inline link the existing renderer already draws).
557
- if (whole[offset + match.length] === '(') return match;
558
- // Nor when it is a task-list checkbox marker at the start of a list
559
- // item (`- [x]`); the checkbox parser owns that `[x]`, and resolving it
560
- // against a stray `x`/`X` definition would clobber the item.
561
- if (/^[ xX]$/.test(text) && /^\s*(?:[-*+]|\d+[.)])\s+$/.test(whole.slice(0, offset))) {
562
- return match;
563
- }
564
- refLabel = text;
565
- } else {
566
- refLabel = label === '' ? text : label;
567
- }
568
- const normalized = normalizeRefLabel(refLabel);
569
- const dest = defs.get(normalized);
570
- // An unknown reference stays literal, matching CommonMark and avoiding
571
- // false links for bracketed prose like `[TODO]` or array indices.
572
- if (!dest) return match;
573
- usedLabels.add(normalized);
574
- return `${bang}[${text}](${dest})`;
575
- },
576
- );
577
- };
578
-
579
- /**
580
- * Resolve CommonMark link reference definitions and reference links into inline
581
- * `[text](url)` links, so the shared inline renderer draws them instead of
582
- * showing raw `[text][id]` and `[id]: url` text (issue #923). Definitions and
583
- * references inside fenced code blocks, raw HTML blocks, and inline code spans
584
- * are left untouched. A definition-shaped line is only ever blanked when its
585
- * label was actually consumed by a resolved reference outside a protected
586
- * region — an unused definition, or one referenced only from inside code/HTML,
587
- * stays visible exactly as written. Blanked lines keep block start-line
588
- * numbers accurate (and their own CRLF ending, so line endings round-trip).
589
- * GFM footnote definitions (`[^label]: ...`) are never treated as link
590
- * definitions. No-op (returns the input) when the document defines no
591
- * (non-footnote) references.
592
- */
593
- export const resolveReferenceLinks = (markdown: string): string => {
594
- if (!markdown.includes('[')) return markdown;
595
- const lines = markdown.split('\n');
596
- const isProtected = markProtectedLines(lines);
597
- const defs = new Map<string, string>();
598
- // The normalized label a definition-shaped line defines, or null if the
599
- // line isn't a definition (or is a footnote definition, which is never
600
- // collected/blanked).
601
- const defLabelByLine = new Array<string | null>(lines.length).fill(null);
602
- // A definition cannot interrupt a paragraph (CommonMark 4.7): a line matching
603
- // the definition shape is only a definition when it can start a block, i.e.
604
- // the previous line is the document start, blank, a protected code/HTML
605
- // line (each is its own block), or itself a definition. Otherwise the line
606
- // is paragraph continuation text and must be left untouched, or a bare
607
- // `[word]: token` under a sentence would be silently deleted.
608
- let canStartDefinition = true;
609
- for (let i = 0; i < lines.length; i++) {
610
- if (isProtected[i]) {
611
- canStartDefinition = true;
612
- continue;
613
- }
614
- const blank = lines[i].trim() === '';
615
- const match = canStartDefinition && !blank ? lines[i].match(REFERENCE_DEFINITION_RE) : null;
616
- if (match) {
617
- const rawLabel = match[1];
618
- // GFM footnote definition ([^label]: ...) — not a link reference
619
- // definition. Leave it out of `defs` entirely so it can never be
620
- // collected, blanked, or accidentally satisfy a footnote reference's
621
- // lookup; it stays block-starting like any other definition line.
622
- if (!rawLabel.startsWith('^') && defs.size < MAX_TRACKED_DEFINITIONS) {
623
- const label = normalizeRefLabel(rawLabel);
624
- const dest = match[2] !== undefined ? match[2] : match[3];
625
- // First definition wins, per CommonMark.
626
- if (label && dest && !defs.has(label)) defs.set(label, dest);
627
- defLabelByLine[i] = label;
628
- }
629
- // A run of definitions stays eligible; canStartDefinition remains true.
630
- } else {
631
- // Blank keeps a new block startable; any other non-definition line starts
632
- // (or continues) a paragraph, so a following definition-shaped line is text.
633
- canStartDefinition = blank;
634
- }
635
- }
636
- if (defs.size === 0) return markdown;
637
- const usedLabels = new Set<string>();
638
- // Resolve references first; definition-shaped lines are passed through
639
- // unresolved (never fed to resolveRefsInLine) so a definition's own
640
- // `[label]` can never be mistaken for a reference to itself.
641
- const resolved = lines.map((line, i) =>
642
- isProtected[i] || defLabelByLine[i] !== null ? line : resolveRefsInLine(line, defs, usedLabels),
643
- );
644
- return resolved
645
- .map((line, i) => {
646
- const label = defLabelByLine[i];
647
- if (label === null || !usedLabels.has(label)) return line;
648
- // Blank in place, preserving this line's own CRLF ending if it had one.
649
- return line.endsWith('\r') ? '\r' : '';
650
- })
651
- .join('\n');
652
- };
653
299
 
654
300
  /**
655
301
  * The block list for a whole-file diagram source (`plannotator annotate
@@ -861,22 +507,17 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
861
507
  continue;
862
508
  }
863
509
 
864
- // Code blocks (naive)
510
+ // Code blocks (naive). Count backticks in the opening fence to support
511
+ // nested fences (e.g. ```` wrapping ```); the extent is shared with core.
865
512
  if (trimmed.startsWith('```')) {
866
513
  flush();
867
514
  const codeStartLine = currentLineNum;
868
- // Count backticks in opening fence to support nested fences (e.g. ```` wrapping ```)
869
515
  const fenceLen = trimmed.match(/^`+/)?.[0].length ?? 3;
870
- const closingFence = new RegExp('^\\s*`{' + fenceLen + ',}');
871
516
  // Extract language from fence (e.g., ```rust → "rust")
872
517
  const language = trimmed.slice(fenceLen).trim() || undefined;
873
- // Fast forward until end of code block
874
- let codeContent = [];
875
- i++; // Skip start fence
876
- while(i < lines.length && !closingFence.test(lines[i])) {
877
- codeContent.push(lines[i]);
878
- i++;
879
- }
518
+ const close = codeFenceCloseIndex(lines, i);
519
+ const codeContent = lines.slice(i + 1, close);
520
+ i = close;
880
521
  blocks.push({
881
522
  id: `block-${currentId++}`,
882
523
  type: 'code',
@@ -888,127 +529,32 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
888
529
  continue;
889
530
  }
890
531
 
891
- // Display math: $$ ... $$ — only when a closing $$ actually exists. An
892
- // unclosed $$ (a stray delimiter or informal money like "$$100k for infra")
893
- // must NOT swallow the rest of the document: scan ahead without committing,
894
- // and if there's no close, fall through and treat the line as ordinary text.
895
- if (trimmed.startsWith('$$')) {
896
- const mathStartLine = currentLineNum;
897
- const afterOpen = trimmed.slice(2);
898
- const mathLines: string[] = [];
899
- let remainder = '';
900
- let closed = false;
901
- let closeLine = i;
902
- // Find the closing $$ anywhere on the opening line (not just at its end),
903
- // so `$$x$$.` or `$$x$$ trailing` close correctly instead of running on.
904
- const inlineClose = afterOpen.indexOf('$$');
905
- if (inlineClose !== -1) {
906
- const body = afterOpen.slice(0, inlineClose).trim();
907
- if (body) mathLines.push(body);
908
- remainder = afterOpen.slice(inlineClose + 2).trim();
909
- closed = true;
910
- } else {
911
- const scanned: string[] = afterOpen.trim() ? [afterOpen.trim()] : [];
912
- let j = i;
913
- while (j + 1 < lines.length) {
914
- j++;
915
- // A blank line ends the search: real $$…$$ has no blank line before its
916
- // close, so a blank means this opener was never closed. Stopping here
917
- // also prevents matching a stray $$ far below (e.g. inside a later code
918
- // fence) and swallowing everything in between.
919
- if (lines[j].trim() === '') break;
920
- const closeAt = lines[j].indexOf('$$');
921
- if (closeAt !== -1) {
922
- const before = lines[j].slice(0, closeAt);
923
- if (before.trim()) scanned.push(before);
924
- remainder = lines[j].slice(closeAt + 2).trim();
925
- closed = true;
926
- closeLine = j;
927
- break;
928
- }
929
- scanned.push(lines[j]);
930
- }
931
- if (closed) for (const s of scanned) mathLines.push(s);
932
- }
933
-
934
- if (closed) {
935
- flush();
936
- i = closeLine;
937
- blocks.push({
938
- id: `block-${currentId++}`,
939
- type: 'math',
940
- content: mathLines.join('\n'),
941
- order: currentId,
942
- startLine: mathStartLine,
943
- sourceLineCount: i + contentStartLine - mathStartLine + 1,
944
- });
945
- // Trailing text after the closing $$ isn't math — reprocess it as its own
946
- // line so it renders normally instead of being swallowed into the block.
947
- if (remainder) {
948
- lines[i] = remainder;
949
- i--;
950
- }
951
- continue;
952
- }
953
- // No closing $$ found — not display math; fall through to normal handling.
954
- }
955
-
956
- // Display math: \[ ... \] — same unclosed-guard as $$ above: only treat as
957
- // math when the closing \] exists, so a stray `\[deprecated\]`-style line or
958
- // an unclosed `\[` doesn't swallow the rest of the document.
959
- if (trimmed.startsWith('\\[')) {
532
+ // Display math: $$ ... $$ or \[ ... \] — only when the close actually
533
+ // exists. An unclosed opener (a stray delimiter, informal money like
534
+ // "$$100k for infra", a `\[deprecated\]`-style line) must NOT swallow the
535
+ // rest of the document: scanDisplayMath scans ahead without committing
536
+ // and answers null, and the line falls through as ordinary text.
537
+ const mathDelimiter = trimmed.startsWith('$$') ? '$$' : trimmed.startsWith('\\[') ? '\\[' : null;
538
+ const math = mathDelimiter ? scanDisplayMath(lines, i, mathDelimiter) : null;
539
+ if (math) {
540
+ flush();
960
541
  const mathStartLine = currentLineNum;
961
- const afterOpen = trimmed.slice(2);
962
- const mathLines: string[] = [];
963
- let remainder = '';
964
- let closed = false;
965
- let closeLine = i;
966
- const inlineClose = afterOpen.indexOf('\\]');
967
- if (inlineClose !== -1) {
968
- const body = afterOpen.slice(0, inlineClose).trim();
969
- if (body) mathLines.push(body);
970
- remainder = afterOpen.slice(inlineClose + 2).trim();
971
- closed = true;
972
- } else {
973
- const scanned: string[] = afterOpen.trim() ? [afterOpen.trim()] : [];
974
- let j = i;
975
- while (j + 1 < lines.length) {
976
- j++;
977
- // Blank line ends the search (see the $$ branch): unclosed \[ must not
978
- // swallow later blocks or match a stray \] inside a later code fence.
979
- if (lines[j].trim() === '') break;
980
- const closeAt = lines[j].indexOf('\\]');
981
- if (closeAt !== -1) {
982
- const before = lines[j].slice(0, closeAt);
983
- if (before.trim()) scanned.push(before);
984
- remainder = lines[j].slice(closeAt + 2).trim();
985
- closed = true;
986
- closeLine = j;
987
- break;
988
- }
989
- scanned.push(lines[j]);
990
- }
991
- if (closed) for (const s of scanned) mathLines.push(s);
992
- }
993
-
994
- if (closed) {
995
- flush();
996
- i = closeLine;
997
- blocks.push({
998
- id: `block-${currentId++}`,
999
- type: 'math',
1000
- content: mathLines.join('\n'),
1001
- order: currentId,
1002
- startLine: mathStartLine,
1003
- sourceLineCount: i + contentStartLine - mathStartLine + 1,
1004
- });
1005
- if (remainder) {
1006
- lines[i] = remainder;
1007
- i--;
1008
- }
1009
- continue;
542
+ i = math.closeLine;
543
+ blocks.push({
544
+ id: `block-${currentId++}`,
545
+ type: 'math',
546
+ content: math.body.join('\n'),
547
+ order: currentId,
548
+ startLine: mathStartLine,
549
+ sourceLineCount: i + contentStartLine - mathStartLine + 1,
550
+ });
551
+ // Trailing text after the close isn't math — reprocess it as its own
552
+ // line so it renders normally instead of being swallowed into the block.
553
+ if (math.remainder) {
554
+ lines[i] = math.remainder;
555
+ i--;
1010
556
  }
1011
- // No closing \] found — not display math; fall through to normal handling.
557
+ continue;
1012
558
  }
1013
559
 
1014
560
  // Tables (lines starting with |)
@@ -1039,27 +585,17 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
1039
585
  continue;
1040
586
  }
1041
587
 
1042
- // Raw HTML blocks. A line starting with a known block-level HTML tag
1043
- // opens an HTML block. For opening tags we accumulate until the matching
1044
- // close tag is balanced (so `<details>…blank line…</details>` renders as
1045
- // one unit, matching GitHub's flavored behavior rather than strict
1046
- // CommonMark §4.6 Type 6 blank-line termination). For a line that starts
1047
- // with a close tag, we fall back to blank-line termination. Content is
1048
- // sanitized at render time, not here.
1049
588
  // Directive container: `:::kind` opens, `:::` closes. Inline kind is
1050
589
  // restricted to simple identifiers (letters, digits, hyphens). Body is
1051
590
  // accumulated verbatim and rendered with inline markdown.
1052
- const directiveOpen = trimmed.match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\s*$/);
591
+ const directiveOpen = trimmed.match(DIRECTIVE_OPEN_RE);
1053
592
  if (directiveOpen) {
1054
593
  flush();
1055
594
  const directiveStartLine = currentLineNum;
1056
595
  const kind = directiveOpen[1].toLowerCase();
1057
- const bodyLines: string[] = [];
1058
- while (i + 1 < lines.length) {
1059
- i++;
1060
- if (lines[i].trim() === ':::') break;
1061
- bodyLines.push(lines[i]);
1062
- }
596
+ const close = directiveCloseIndex(lines, i);
597
+ const bodyLines = lines.slice(i + 1, close);
598
+ i = close;
1063
599
  blocks.push({
1064
600
  id: `block-${currentId++}`,
1065
601
  type: 'directive',
@@ -1071,43 +607,19 @@ export const parseMarkdownToBlocks = (markdown: string, options?: ParseMarkdownO
1071
607
  continue;
1072
608
  }
1073
609
 
1074
- const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE);
1075
- if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) {
610
+ // Raw HTML blocks. A line starting with a known block-level HTML tag
611
+ // opens an HTML block. For opening tags we accumulate until the matching
612
+ // close tag is balanced (so `<details>…blank line…</details>` renders as
613
+ // one unit, matching GitHub's flavored behavior rather than strict
614
+ // CommonMark §4.6 Type 6 blank-line termination). For a line that starts
615
+ // with a close tag, we fall back to blank-line termination. Content is
616
+ // sanitized at render time, not here.
617
+ const htmlEnd = htmlBlockEndAt(lines, i, htmlCloseCache);
618
+ if (htmlEnd !== -1) {
1076
619
  flush();
1077
620
  const htmlStartLine = currentLineNum;
1078
- const tagName = htmlTagMatch[1].toLowerCase();
1079
- const isCloseTag = trimmed.startsWith('</');
1080
- const htmlLines: string[] = [line];
1081
-
1082
- if (isCloseTag) {
1083
- while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
1084
- i++;
1085
- htmlLines.push(lines[i]);
1086
- }
1087
- } else if (VOID_HTML_TAGS.has(tagName)) {
1088
- // Void element (e.g. <img>): no closing tag, but attributes can wrap across
1089
- // lines. Consume until the line that actually closes the tag with `>` so a
1090
- // multi-line <img> isn't truncated to a bare `<img` fragment.
1091
- while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
1092
- i++;
1093
- htmlLines.push(lines[i]);
1094
- }
1095
- } else {
1096
- const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
1097
- const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
1098
- const depth = (line.match(openRe) || []).length - (line.match(closeRe) || []).length;
1099
- // Scan ahead for the matching close tag via the shared, bounded/
1100
- // linear helper (see its doc comment for why a naive per-opener scan
1101
- // is quadratic). If none is ever found — a self-closing <video/>, or
1102
- // an unclosed <picture>/<div> — do NOT swallow the rest of the
1103
- // document into this block; keep it to the opening line.
1104
- const end = findHtmlBlockEnd(lines, i, tagName, depth, htmlCloseCache);
1105
- if (end > i) {
1106
- for (let k = i + 1; k <= end; k++) htmlLines.push(lines[k]);
1107
- i = end;
1108
- }
1109
- }
1110
-
621
+ const htmlLines = lines.slice(i, htmlEnd + 1);
622
+ i = htmlEnd;
1111
623
  blocks.push({
1112
624
  id: `block-${currentId++}`,
1113
625
  type: 'html',
@@ -1380,6 +892,11 @@ const additionalTargetsExportBlock = (ann: any): string => {
1380
892
  * panel chrome does not use it.
1381
893
  */
1382
894
  export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOptions = { includeRoute: true }): string => {
895
+ // An answer to a `:::question` block: the question and the answer lines.
896
+ const answer = ann?.questionAnswer == null ? null : parseQuestionAnswer(ann.questionAnswer);
897
+ if (answer) {
898
+ return `Answer to the question "${safeInline(answer.prompt, 400)}"\n${formatQuestionAnswerLines(answer)}`;
899
+ }
1383
900
  let output = '';
1384
901
  switch (ann?.type) {
1385
902
  case 'DELETION':
@@ -1431,15 +948,47 @@ const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
1431
948
  return `lines ${block.startLine}–${end}`;
1432
949
  };
1433
950
 
951
+ /** Separate valid question answers from ordinary feedback. A row whose
952
+ * `questionAnswer` fails validation stays ordinary feedback (its one-line
953
+ * text still reads), so nothing is ever dropped. */
954
+ const splitQuestionAnswers = (list: any[]): { answers: QuestionAnswer[]; annotations: any[] } => {
955
+ const answers: QuestionAnswer[] = [];
956
+ const annotations: any[] = [];
957
+ for (const ann of list) {
958
+ const answer = ann?.questionAnswer == null ? null : parseQuestionAnswer(ann.questionAnswer);
959
+ if (answer) answers.push(answer);
960
+ else annotations.push(ann);
961
+ }
962
+ return { answers, annotations };
963
+ };
964
+
965
+ /** Export items when a document's blocks are unknown: the answers' own
966
+ * prompts and lines, numbered in answer order. */
967
+ const questionItemsFromAnswers = (answers: QuestionAnswer[]): QuestionExportItem[] =>
968
+ answers.map((a, i) => ({
969
+ key: a.key,
970
+ number: i + 1,
971
+ prompt: a.prompt,
972
+ ...(a.sourceLine ? { line: a.sourceLine } : {}),
973
+ recommendedLabels: [],
974
+ settled: false,
975
+ }));
976
+
1434
977
  export const exportAnnotations = (
1435
978
  blocks: Block[],
1436
- annotations: any[],
979
+ allAnnotations: any[],
1437
980
  globalAttachments: ImageAttachment[] = [],
1438
981
  title: string = 'Plan Feedback',
1439
982
  subject: string = 'plan',
1440
983
  opts: ExportAnnotationsOptions = {},
1441
984
  ): string => {
1442
- if (annotations.length === 0 && globalAttachments.length === 0) {
985
+ // Answers to `:::question` blocks are printed first, in their own section,
986
+ // and never counted as numbered feedback.
987
+ const { answers, annotations } = splitQuestionAnswers(allAnnotations);
988
+ const answersSection = answers.length > 0
989
+ ? formatQuestionAnswersSection(questionExportItems(indexQuestionBlocks(blocks)), answers, { headingLevel: 2 })
990
+ : '';
991
+ if (annotations.length === 0 && globalAttachments.length === 0 && !answersSection) {
1443
992
  return 'No changes detected.';
1444
993
  }
1445
994
 
@@ -1461,6 +1010,8 @@ export const exportAnnotations = (
1461
1010
  output += `> Note: Line numbers below refer to the converted markdown, not the original HTML/URL source.\n\n`;
1462
1011
  }
1463
1012
 
1013
+ output += answersSection;
1014
+
1464
1015
  // Add global reference images section if any
1465
1016
  if (globalAttachments.length > 0) {
1466
1017
  output += `## Reference Images\n`;
@@ -1673,11 +1224,20 @@ export const exportLinkedDocAnnotations = (
1673
1224
  // One injection per export, across all linked documents.
1674
1225
  const injectedSkills = new Set<string>();
1675
1226
 
1676
- for (const [filepath, { annotations, globalAttachments, blocks: docBlocks, isConverted }] of docAnnotations) {
1677
- if (annotations.length === 0 && globalAttachments.length === 0) continue;
1227
+ for (const [filepath, { annotations: docAnnotationList, globalAttachments, blocks: docBlocks, markdown: docMarkdown, isConverted }] of docAnnotations) {
1228
+ if (docAnnotationList.length === 0 && globalAttachments.length === 0) continue;
1229
+ const { answers, annotations } = splitQuestionAnswers(docAnnotationList);
1678
1230
 
1679
1231
  output += `## ${filepath}${isConverted ? ' (converted from HTML — line numbers refer to converted markdown)' : ''}\n\n`;
1680
1232
 
1233
+ if (answers.length > 0) {
1234
+ const questionBlocks = docBlocks ?? (docMarkdown !== undefined ? parseMarkdownToBlocks(docMarkdown) : null);
1235
+ const items = questionBlocks
1236
+ ? questionExportItems(indexQuestionBlocks(questionBlocks))
1237
+ : questionItemsFromAnswers(answers);
1238
+ output += formatQuestionAnswersSection(items, answers, { headingLevel: 3 });
1239
+ }
1240
+
1681
1241
  if (globalAttachments.length > 0) {
1682
1242
  output += `### Reference Images\n`;
1683
1243
  output += `Please review these reference images (use the Read tool to view):\n`;
@@ -1693,7 +1253,9 @@ export const exportLinkedDocAnnotations = (
1693
1253
  return a.startOffset - b.startOffset;
1694
1254
  });
1695
1255
 
1696
- output += `I've reviewed this document and have ${annotations.length} piece${annotations.length !== 1 ? 's' : ''} of feedback:\n\n`;
1256
+ if (annotations.length > 0 || answers.length === 0) {
1257
+ output += `I've reviewed this document and have ${annotations.length} piece${annotations.length !== 1 ? 's' : ''} of feedback:\n\n`;
1258
+ }
1697
1259
 
1698
1260
  sortedAnns.forEach((ann, index) => {
1699
1261
  output += `### ${index + 1}. `;