@plannotator/ui 0.52.1 → 0.53.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 (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Plain-text paste into our own text fields, inserted by the page.
3
+ *
4
+ * Works around a Chrome 155 to 156 bug on macOS, and runs only there
5
+ * (`chromiumPasteBugAffected`). Remove after Chrome 157 is the stable channel
6
+ * (it ships the fix).
7
+ *
8
+ * Chrome 155 added a check after the `paste` event (Chromium CL 8217390,
9
+ * commit 4f9d868279, "Ensure default paste behavior doesn't leak changed
10
+ * clipboard content"): the browser's own insertion is skipped when the
11
+ * clipboard's sequence token changed while the event ran. On macOS an older
12
+ * race in ClipboardMac can give the same NSPasteboard changeCount a second
13
+ * token when a paste starts right after an app wrote the pasteboard and
14
+ * posted a synthetic ⌘V. Dictation apps (Hex) and text expanders do exactly
15
+ * that. The `paste` event still carries the right `text/plain`, but
16
+ * `beforeinput` arrives with empty data and nothing is inserted: the first
17
+ * dictation after a page load lands, later ones vanish. Fixed in Chromium
18
+ * main (CL 8503629, commit fc5529f522, crbug 568453825), not before 157.
19
+ *
20
+ * So, for a paste into a plain-text field we own (a textarea or a text-like
21
+ * input) that nothing else handled, that carries no files and has text, the
22
+ * page cancels the browser's insertion and inserts the text itself with
23
+ * `execCommand('insertText')` (keeps undo, fires `input`, so React's onChange
24
+ * runs), else `setRangeText` plus an `input` event. That is what the browser
25
+ * would have done. It is gated to the affected engine: Safari's execCommand
26
+ * merges the insertion with the typing before it in the undo stack, so
27
+ * everywhere else the browser's own paste is kept. Only trusted (real) paste
28
+ * events are acted on.
29
+ *
30
+ * Left native: a paste that a handler already cancelled (image paste to
31
+ * attach), one that carries files (paste-to-attach keeps working; the
32
+ * browser inserts the text beside them), an empty one, a field that is
33
+ * read-only or disabled, one that is not focused, one mid IME composition,
34
+ * contenteditable and code editors (CodeMirror, Pierre's editor, the agent
35
+ * terminal's helper textarea), and a field marked `data-native-paste`.
36
+ */
37
+
38
+ /** Input types the page can insert into with a caret (email and number have no selection API). */
39
+ const TEXT_INPUT_TYPES = new Set(['text', 'search', 'url', 'tel', 'password']);
40
+
41
+ /** Editors that read the paste themselves through a hidden textarea or own their own model. */
42
+ const NATIVE_PASTE = '[data-native-paste], .xterm, .cm-editor, .monaco-editor';
43
+
44
+ export type PlainTextField = HTMLTextAreaElement | HTMLInputElement;
45
+
46
+ export interface PasteInsertion {
47
+ field: PlainTextField;
48
+ text: string;
49
+ start: number;
50
+ end: number;
51
+ }
52
+
53
+ type PasteLike = Pick<ClipboardEvent, 'defaultPrevented' | 'clipboardData' | 'target'> & {
54
+ composedPath?: () => EventTarget[];
55
+ };
56
+
57
+ function plainTextField(target: EventTarget | null | undefined): PlainTextField | null {
58
+ if (!target || typeof (target as Node).nodeType !== 'number') return null;
59
+ const el = target as Element;
60
+ const view = el.ownerDocument?.defaultView;
61
+ if (!view) return null;
62
+ if (el instanceof view.HTMLTextAreaElement) return el;
63
+ if (el instanceof view.HTMLInputElement && TEXT_INPUT_TYPES.has(el.type)) return el;
64
+ return null;
65
+ }
66
+
67
+ function carriesFiles(data: DataTransfer): boolean {
68
+ if ((data.files?.length ?? 0) > 0) return true;
69
+ for (const item of Array.from(data.items ?? [])) {
70
+ if (item.kind === 'file') return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ function focused(field: PlainTextField): boolean {
76
+ const root = field.getRootNode() as Document | ShadowRoot;
77
+ return 'activeElement' in root && root.activeElement === field;
78
+ }
79
+
80
+ /** Cut to `room` UTF-16 units without splitting a surrogate pair. */
81
+ function truncate(text: string, room: number): string {
82
+ if (text.length <= room) return text;
83
+ let cut = room;
84
+ const last = text.charCodeAt(cut - 1);
85
+ if (cut > 0 && last >= 0xd800 && last <= 0xdbff) cut -= 1;
86
+ return text.slice(0, cut);
87
+ }
88
+
89
+ /**
90
+ * What the page should insert for this paste, or null to leave it to the
91
+ * browser. Pure apart from reading the event and the field.
92
+ */
93
+ export function plainTextPasteInsertion(event: PasteLike, composing = false): PasteInsertion | null {
94
+ if (event.defaultPrevented || composing) return null;
95
+ const data = event.clipboardData;
96
+ if (!data) return null;
97
+ const path = event.composedPath?.() ?? [];
98
+ const field = plainTextField(path[0] ?? event.target);
99
+ if (!field) return null;
100
+ if (field.readOnly || field.disabled) return null;
101
+ if (field.closest(NATIVE_PASTE)) return null;
102
+ if (!focused(field)) return null;
103
+ if (carriesFiles(data)) return null;
104
+ let text = data.getData('text/plain');
105
+ if (!text) return null;
106
+ // What Chrome does with line breaks: a textarea keeps each as \n, a one-line input turns each into a space.
107
+ text = text.replace(/\r\n?/g, '\n');
108
+ if (field.tagName !== 'TEXTAREA') text = text.replace(/\n/g, ' ');
109
+ if (!text) return null;
110
+ const start = field.selectionStart;
111
+ const end = field.selectionEnd;
112
+ if (start === null || end === null) return null;
113
+ if (field.maxLength >= 0) {
114
+ const room = field.maxLength - (field.value.length - (end - start));
115
+ if (room <= 0) return null; // the browser inserts nothing either
116
+ text = truncate(text, room);
117
+ if (!text) return null;
118
+ }
119
+ return { field, text, start, end };
120
+ }
121
+
122
+ /** Insert as typing would: an undo step and an `input` event. */
123
+ export function insertPastedText({ field, text, start, end }: PasteInsertion): void {
124
+ const doc = field.ownerDocument;
125
+ let inserted = false;
126
+ try {
127
+ inserted = typeof doc.execCommand === 'function' && doc.execCommand('insertText', false, text) === true;
128
+ } catch {
129
+ inserted = false;
130
+ }
131
+ if (inserted) return;
132
+ field.setRangeText(text, start, end, 'end');
133
+ const caret = start + text.length;
134
+ field.setSelectionRange(caret, caret); // some engines put the caret elsewhere after setRangeText
135
+ const view = doc.defaultView;
136
+ const event =
137
+ view && typeof view.InputEvent === 'function'
138
+ ? new view.InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })
139
+ : new Event('input', { bubbles: true });
140
+ field.dispatchEvent(event);
141
+ }
142
+
143
+ /** What the gate reads off `navigator` (User-Agent Client Hints are Chromium-only). */
144
+ export interface PasteEngineNavigator {
145
+ userAgent?: string;
146
+ platform?: string;
147
+ userAgentData?: { brands?: ReadonlyArray<{ brand: string; version: string }>; platform?: string } | null;
148
+ }
149
+
150
+ /** The Chromium majors with the bug: 155 added the check, 157 ships the fix. */
151
+ const AFFECTED_CHROMIUM = new Set([155, 156]);
152
+
153
+ /**
154
+ * Only Chromium 155 and 156 on macOS have the bug, so only they get the
155
+ * workaround: it turns itself off when 157 arrives, and Safari, Firefox and
156
+ * older Chromium (VS Code's webview) keep the browser's own paste. Safari
157
+ * matters most: there `execCommand('insertText')` merges with the typing
158
+ * before it, so one ⌘Z after a paste would also erase what was typed.
159
+ * Chrome, Edge, Brave, Arc and other Chromium browsers are told by the
160
+ * "Chromium" brand of User-Agent Client Hints, else by `Chrome/<major>` in
161
+ * the UA string (Safari and Firefox carry no `Chrome/`; Chrome on iOS is
162
+ * WebKit and says `CriOS/`).
163
+ */
164
+ export function chromiumPasteBugAffected(nav: PasteEngineNavigator | undefined | null): boolean {
165
+ if (!nav) return false;
166
+ const ua = nav.userAgent ?? '';
167
+ const data = nav.userAgentData ?? null;
168
+ let major = Number.NaN;
169
+ const brand = data?.brands?.find((b) => b.brand === 'Chromium');
170
+ if (brand) major = Number.parseInt(brand.version, 10);
171
+ if (Number.isNaN(major)) {
172
+ const match = /Chrome\/(\d+)/.exec(ua);
173
+ if (match) major = Number.parseInt(match[1]!, 10);
174
+ }
175
+ if (!AFFECTED_CHROMIUM.has(major)) return false;
176
+ // Client hints name the platform when present; else navigator.platform or the UA string.
177
+ if (data?.platform) return /^mac/i.test(data.platform);
178
+ return /^mac/i.test(nav.platform ?? '') || /Macintosh|Mac OS X/.test(ua);
179
+ }
180
+
181
+ /**
182
+ * Tests (the e2e simulation, in a Chromium that is not 155 or 156) set this
183
+ * to `true` on the window to turn the workaround on whatever the engine.
184
+ */
185
+ export const FORCE_PLAIN_TEXT_PASTE_FLAG = '__plannotatorForcePlainTextPaste';
186
+
187
+ export interface PlainTextPasteOptions {
188
+ /** Whether the workaround runs. Default: the engine gate, or the window's force flag. */
189
+ enabled?: boolean;
190
+ /** Act on script-dispatched (untrusted) paste events too; for tests. Default false. */
191
+ allowUntrusted?: boolean;
192
+ }
193
+
194
+ /**
195
+ * Install on a window; returns the uninstall. Listens in the bubble phase on
196
+ * the window, so every handler on the page (React's, the image-paste ones)
197
+ * runs first and can claim the paste. Does nothing outside the affected
198
+ * engine (`chromiumPasteBugAffected`).
199
+ */
200
+ export function installPlainTextPaste(win: Window = window, options: PlainTextPasteOptions = {}): () => void {
201
+ const doc = win.document;
202
+ const engine = chromiumPasteBugAffected(win.navigator as unknown as PasteEngineNavigator);
203
+ const forced = () => (win as unknown as Record<string, unknown>)[FORCE_PLAIN_TEXT_PASTE_FLAG] === true;
204
+ const enabled = () => options.enabled ?? (engine || forced());
205
+ let composing = false;
206
+ const onCompositionStart = () => {
207
+ composing = true;
208
+ };
209
+ // Also on focus moving and the window losing focus: a compositionend that never arrives must not
210
+ // leave the workaround off until reload.
211
+ const onCompositionEnd = () => {
212
+ composing = false;
213
+ };
214
+ const onPaste = (event: ClipboardEvent) => {
215
+ if (!enabled()) return;
216
+ if (!event.isTrusted && !options.allowUntrusted) return; // a script's paste event inserts nothing
217
+ const insertion = plainTextPasteInsertion(event, composing);
218
+ if (!insertion) return;
219
+ event.preventDefault();
220
+ insertPastedText(insertion);
221
+ };
222
+ doc.addEventListener('compositionstart', onCompositionStart, true);
223
+ doc.addEventListener('compositionend', onCompositionEnd, true);
224
+ doc.addEventListener('focusin', onCompositionEnd, true);
225
+ win.addEventListener('blur', onCompositionEnd);
226
+ win.addEventListener('paste', onPaste);
227
+ return () => {
228
+ doc.removeEventListener('compositionstart', onCompositionStart, true);
229
+ doc.removeEventListener('compositionend', onCompositionEnd, true);
230
+ doc.removeEventListener('focusin', onCompositionEnd, true);
231
+ win.removeEventListener('blur', onCompositionEnd);
232
+ win.removeEventListener('paste', onPaste);
233
+ };
234
+ }
@@ -43,6 +43,17 @@ export interface PlanDiffBlock {
43
43
  inlineTokens?: InlineDiffToken[];
44
44
  /** Structural wrap metadata paired with inlineTokens. */
45
45
  inlineWrap?: InlineDiffWrap;
46
+ /**
47
+ * Source line range of this block in the OLD text: 1-based, inclusive,
48
+ * trimmed of leading and trailing blank lines. Absent when the block has
49
+ * no non-blank old line (added blocks, or an all-blank chunk). Filled by
50
+ * `computePlanDiff`; plan review ignores it.
51
+ */
52
+ oldStart?: number;
53
+ oldEnd?: number;
54
+ /** Same as `oldStart`/`oldEnd`, in the NEW text (absent on removed blocks). */
55
+ newStart?: number;
56
+ newEnd?: number;
46
57
  }
47
58
 
48
59
  export interface PlanDiffStats {
@@ -63,6 +74,35 @@ function countLines(text: string): number {
63
74
  return lines.length;
64
75
  }
65
76
 
77
+ /**
78
+ * The trimmed line range a chunk occupies when its first line is `start`
79
+ * (1-based): leading and trailing blank lines are not counted. Empty when the
80
+ * chunk has no non-blank line.
81
+ */
82
+ function trimmedLineRange(
83
+ value: string,
84
+ start: number
85
+ ): { start?: number; end?: number } {
86
+ const lines = value.split("\n");
87
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
88
+ let first = 0;
89
+ while (first < lines.length && lines[first].trim() === "") first++;
90
+ if (first >= lines.length) return {};
91
+ let last = lines.length - 1;
92
+ while (last > first && lines[last].trim() === "") last--;
93
+ return { start: start + first, end: start + last };
94
+ }
95
+
96
+ function oldRange(value: string, start: number): Pick<PlanDiffBlock, "oldStart" | "oldEnd"> {
97
+ const r = trimmedLineRange(value, start);
98
+ return r.start === undefined ? {} : { oldStart: r.start, oldEnd: r.end };
99
+ }
100
+
101
+ function newRange(value: string, start: number): Pick<PlanDiffBlock, "newStart" | "newEnd"> {
102
+ const r = trimmedLineRange(value, start);
103
+ return r.start === undefined ? {} : { newStart: r.start, newEnd: r.end };
104
+ }
105
+
66
106
  const INLINE_DIFFABLE_TYPES = new Set<Block["type"]>([
67
107
  "paragraph",
68
108
  "heading",
@@ -213,6 +253,34 @@ function substituteFencedBlocks(
213
253
  });
214
254
  }
215
255
 
256
+ // A quote (a run of `>` lines) that holds a code fence is one block in the
257
+ // rendered plan, and each diff chunk renders as its own quote. Diffed line
258
+ // by line, an edit inside the quoted fence split it into chunks holding half
259
+ // a fence each, so one chunk opened a fence it never closed and rendered the
260
+ // prose after it as code. The whole run is therefore one atomic unit, like
261
+ // an unquoted fence. Quotes without a fence keep line-level diffing. Runs
262
+ // after substituteFencedBlocks, so a `>` line inside an unquoted fence is
263
+ // already behind its sentinel and never starts a run.
264
+ const QUOTE_RUN_PATTERN = /^[ \t]*>[^\n]*(?:\n[ \t]*>[^\n]*)*/gm;
265
+ const QUOTED_FENCE_LINE = /^[ \t]*(?:>[ \t]?)+[ \t]*`{3,}/m;
266
+
267
+ function substituteQuotedFenceRuns(
268
+ text: string,
269
+ fenceMap: Map<string, string>,
270
+ fenceToId: Map<string, number>
271
+ ): string {
272
+ return text.replace(QUOTE_RUN_PATTERN, (match) => {
273
+ if (!QUOTED_FENCE_LINE.test(match)) return match;
274
+ let id = fenceToId.get(match);
275
+ if (id === undefined) {
276
+ id = fenceToId.size;
277
+ fenceMap.set(fenceSentinelFor(id), match);
278
+ fenceToId.set(match, id);
279
+ }
280
+ return fenceSentinelFor(id);
281
+ });
282
+ }
283
+
216
284
  function restoreFencedBlocks(
217
285
  value: string,
218
286
  fenceMap: Map<string, string>
@@ -530,8 +598,16 @@ export function computePlanDiff(
530
598
  // FENCE_BLOCK_PATTERN for the failure this prevents.
531
599
  const fenceMap = new Map<string, string>();
532
600
  const fenceToId = new Map<string, number>();
533
- const substOld = substituteFencedBlocks(oldText, fenceMap, fenceToId);
534
- const substNew = substituteFencedBlocks(newText, fenceMap, fenceToId);
601
+ const substOld = substituteQuotedFenceRuns(
602
+ substituteFencedBlocks(oldText, fenceMap, fenceToId),
603
+ fenceMap,
604
+ fenceToId,
605
+ );
606
+ const substNew = substituteQuotedFenceRuns(
607
+ substituteFencedBlocks(newText, fenceMap, fenceToId),
608
+ fenceMap,
609
+ fenceToId,
610
+ );
535
611
 
536
612
  const rawChanges: Change[] = diffLines(substOld, substNew);
537
613
  // Restore the fenced-block content on each change value before the
@@ -544,6 +620,10 @@ export function computePlanDiff(
544
620
 
545
621
  const blocks: PlanDiffBlock[] = [];
546
622
  const stats: PlanDiffStats = { additions: 0, deletions: 0, modifications: 0 };
623
+ // First line (1-based) of the next chunk on each side; the restored chunk
624
+ // values hold the real source lines, so counting them keeps both in step.
625
+ let oldLine = 1;
626
+ let newLine = 1;
547
627
 
548
628
  for (let i = 0; i < changes.length; i++) {
549
629
  const change = changes[i];
@@ -558,7 +638,11 @@ export function computePlanDiff(
558
638
  oldContent: change.value,
559
639
  lines: countLines(next.value),
560
640
  ...(inline ? { inlineTokens: inline.tokens, inlineWrap: inline.wrap } : {}),
641
+ ...oldRange(change.value, oldLine),
642
+ ...newRange(next.value, newLine),
561
643
  });
644
+ oldLine += countLines(change.value);
645
+ newLine += countLines(next.value);
562
646
  stats.modifications++;
563
647
  stats.additions += countLines(next.value);
564
648
  stats.deletions += countLines(change.value);
@@ -568,23 +652,209 @@ export function computePlanDiff(
568
652
  type: "added",
569
653
  content: change.value,
570
654
  lines: countLines(change.value),
655
+ ...newRange(change.value, newLine),
571
656
  });
657
+ newLine += countLines(change.value);
572
658
  stats.additions += countLines(change.value);
573
659
  } else if (change.removed) {
574
660
  blocks.push({
575
661
  type: "removed",
576
662
  content: change.value,
577
663
  lines: countLines(change.value),
664
+ ...oldRange(change.value, oldLine),
578
665
  });
666
+ oldLine += countLines(change.value);
579
667
  stats.deletions += countLines(change.value);
580
668
  } else {
581
669
  blocks.push({
582
670
  type: "unchanged",
583
671
  content: change.value,
584
672
  lines: countLines(change.value),
673
+ ...oldRange(change.value, oldLine),
674
+ ...newRange(change.value, newLine),
585
675
  });
676
+ oldLine += countLines(change.value);
677
+ newLine += countLines(change.value);
586
678
  }
587
679
  }
588
680
 
589
681
  return { blocks, stats };
590
682
  }
683
+
684
+ // --- Folding unchanged runs (code review's rendered Markdown diff) ---------
685
+
686
+ /** A run of unchanged sections hidden behind one row. */
687
+ export interface PlanDiffFold {
688
+ kind: "fold";
689
+ /** Stable for the same diff: `fold-<block index>-<first hidden piece>`. */
690
+ key: string;
691
+ /** The hidden sections, each an 'unchanged' block with its own ranges. */
692
+ pieces: PlanDiffBlock[];
693
+ /** How many sections are hidden (`pieces.length`). */
694
+ sectionCount: number;
695
+ /** Plain text of the hidden headings, at most three. */
696
+ headings: string[];
697
+ oldStart?: number;
698
+ oldEnd?: number;
699
+ newStart?: number;
700
+ newEnd?: number;
701
+ }
702
+
703
+ export type PlanDiffSegment =
704
+ | { kind: "block"; block: PlanDiffBlock; index: number }
705
+ | PlanDiffFold;
706
+
707
+ const MAX_FOLD_HEADINGS = 3;
708
+
709
+ /** Heading text without inline markdown markers. */
710
+ function plainHeadingText(text: string): string {
711
+ return text
712
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
713
+ .replace(/[*_`~]/g, "")
714
+ .replace(/\s+/g, " ")
715
+ .trim();
716
+ }
717
+
718
+ interface UnchangedPiece {
719
+ piece: PlanDiffBlock;
720
+ heading?: string;
721
+ }
722
+
723
+ /**
724
+ * Split an unchanged chunk into one piece per markdown block: each piece is
725
+ * the exact source text from its block's first line up to the next block's
726
+ * (blank lines stay with the piece before them; anything before the first
727
+ * block, such as frontmatter, stays with the first piece). Concatenating the
728
+ * pieces gives the chunk back byte for byte.
729
+ */
730
+ function splitUnchangedChunk(block: PlanDiffBlock): UnchangedPiece[] {
731
+ const content = block.content;
732
+ const parsed = parseMarkdownToBlocks(content);
733
+ if (parsed.length <= 1) {
734
+ const only = parsed[0];
735
+ return [only?.type === "heading" ? { piece: block, heading: plainHeadingText(only.content) } : { piece: block }];
736
+ }
737
+
738
+ // Character offset of the start of each 1-based line.
739
+ const lineOffsets: number[] = [0];
740
+ for (let i = 0; i < content.length; i++) {
741
+ if (content[i] === "\n") lineOffsets.push(i + 1);
742
+ }
743
+ const lineCount = countLines(content);
744
+ const starts = Array.from(
745
+ new Set(parsed.map((b) => b.startLine).filter((line) => line >= 1 && line <= lineCount))
746
+ ).sort((a, b) => a - b);
747
+ if (starts.length === 0) return [{ piece: block }];
748
+ starts[0] = 1;
749
+
750
+ // The chunk's untrimmed first line on each side (its ranges are trimmed).
751
+ const contentLines = content.split("\n");
752
+ let leadingBlank = 0;
753
+ while (leadingBlank < contentLines.length && contentLines[leadingBlank].trim() === "") leadingBlank++;
754
+ const oldBase = block.oldStart !== undefined ? block.oldStart - leadingBlank : undefined;
755
+ const newBase = block.newStart !== undefined ? block.newStart - leadingBlank : undefined;
756
+
757
+ const headingByStart = new Map<number, string>();
758
+ for (const b of parsed) {
759
+ if (b.type === "heading" && !headingByStart.has(b.startLine)) {
760
+ headingByStart.set(b.startLine, plainHeadingText(b.content));
761
+ }
762
+ }
763
+
764
+ return starts.map((startLine, i) => {
765
+ const nextStart = starts[i + 1];
766
+ const from = lineOffsets[startLine - 1] ?? content.length;
767
+ const to = nextStart !== undefined ? lineOffsets[nextStart - 1] ?? content.length : content.length;
768
+ const pieceContent = content.slice(from, to);
769
+ const piece: PlanDiffBlock = {
770
+ type: "unchanged",
771
+ content: pieceContent,
772
+ lines: countLines(pieceContent),
773
+ ...(oldBase !== undefined ? oldRange(pieceContent, oldBase + startLine - 1) : {}),
774
+ ...(newBase !== undefined ? newRange(pieceContent, newBase + startLine - 1) : {}),
775
+ };
776
+ // A heading piece begins at the heading itself (i > 0), or the first
777
+ // piece's parsed block is the heading.
778
+ const heading = headingByStart.get(i === 0 ? parsed[0].startLine : startLine);
779
+ return heading !== undefined ? { piece, heading } : { piece };
780
+ });
781
+ }
782
+
783
+ /** The span of several pieces' ranges on each side. */
784
+ function spanOf(pieces: PlanDiffBlock[]): Pick<PlanDiffBlock, "oldStart" | "oldEnd" | "newStart" | "newEnd"> {
785
+ const withOld = pieces.filter((p) => p.oldStart !== undefined);
786
+ const withNew = pieces.filter((p) => p.newStart !== undefined);
787
+ return {
788
+ ...(withOld.length > 0
789
+ ? { oldStart: withOld[0].oldStart, oldEnd: withOld[withOld.length - 1].oldEnd }
790
+ : {}),
791
+ ...(withNew.length > 0
792
+ ? { newStart: withNew[0].newStart, newEnd: withNew[withNew.length - 1].newEnd }
793
+ : {}),
794
+ };
795
+ }
796
+
797
+ /** Consecutive pieces as one unchanged block (kept together so list numbering stays continuous). */
798
+ function joinPieces(pieces: PlanDiffBlock[]): PlanDiffBlock {
799
+ if (pieces.length === 1) return pieces[0];
800
+ const content = pieces.map((p) => p.content).join("");
801
+ return { type: "unchanged", content, lines: countLines(content), ...spanOf(pieces) };
802
+ }
803
+
804
+ /**
805
+ * Fold long runs of unchanged sections behind one row, the way a code diff
806
+ * folds unmodified lines. Changed blocks pass through. Each unchanged block
807
+ * keeps `context` sections next to each neighbouring change visible (none
808
+ * toward the start or end of the document) and folds the rest when at least
809
+ * `minHidden` would be hidden; otherwise it is shown whole, untouched.
810
+ *
811
+ * Pure: the same blocks always give the same segments and fold keys.
812
+ */
813
+ export function foldUnchangedPlanDiff(
814
+ blocks: PlanDiffBlock[],
815
+ options: { context?: number; minHidden?: number } = {}
816
+ ): PlanDiffSegment[] {
817
+ const context = Math.max(0, options.context ?? 1);
818
+ const minHidden = Math.max(1, options.minHidden ?? 2);
819
+ const segments: PlanDiffSegment[] = [];
820
+
821
+ blocks.forEach((block, index) => {
822
+ if (block.type !== "unchanged") {
823
+ segments.push({ kind: "block", block, index });
824
+ return;
825
+ }
826
+ const pieces = splitUnchangedChunk(block);
827
+ const head = index > 0 ? Math.min(context, pieces.length) : 0;
828
+ const tail = index < blocks.length - 1 ? Math.min(context, pieces.length - head) : 0;
829
+ const hidden = pieces.length - head - tail;
830
+ if (hidden < minHidden) {
831
+ segments.push({ kind: "block", block, index });
832
+ return;
833
+ }
834
+ if (head > 0) {
835
+ segments.push({ kind: "block", block: joinPieces(pieces.slice(0, head).map((p) => p.piece)), index });
836
+ }
837
+ const folded = pieces.slice(head, head + hidden);
838
+ const foldPieces = folded.map((p) => p.piece);
839
+ segments.push({
840
+ kind: "fold",
841
+ key: `fold-${index}-${head}`,
842
+ pieces: foldPieces,
843
+ sectionCount: foldPieces.length,
844
+ headings: folded
845
+ .map((p) => p.heading)
846
+ .filter((h): h is string => !!h)
847
+ .slice(0, MAX_FOLD_HEADINGS),
848
+ ...spanOf(foldPieces),
849
+ });
850
+ if (tail > 0) {
851
+ segments.push({
852
+ kind: "block",
853
+ block: joinPieces(pieces.slice(head + hidden).map((p) => p.piece)),
854
+ index,
855
+ });
856
+ }
857
+ });
858
+
859
+ return segments;
860
+ }
@@ -81,6 +81,18 @@ async function requestCatalog(): Promise<SkillCatalogEntry[]> {
81
81
  }
82
82
  }
83
83
 
84
+ // Whether a fetched catalog is registered for the per-comment feedback export
85
+ // (skillReferenceExportBlock). Default on: Plannotator's exporters list each
86
+ // comment's references. A host that composes ONE block per message itself
87
+ // (the Plannotator Inbox) turns it off, so a comment's `$name` stays plain
88
+ // text in its export instead of carrying a second "Skills referenced" list.
89
+ let exportRegistration = true;
90
+
91
+ export function setSkillCatalogExportRegistration(on: boolean): void {
92
+ exportRegistration = on;
93
+ if (!on) setSkillCatalogForExport([]);
94
+ }
95
+
84
96
  let cached: { at: number; skills: SkillCatalogEntry[] } | null = null;
85
97
  let inflight: Promise<SkillCatalogEntry[]> | null = null;
86
98
  // Bumped by resetSkillCatalogCache. A request that resolves after a reset (or
@@ -111,7 +123,7 @@ export function fetchSkillCatalog(): Promise<SkillCatalogEntry[]> {
111
123
  } else {
112
124
  cached = { at: Date.now(), skills: cached.skills };
113
125
  }
114
- setSkillCatalogForExport(cached.skills);
126
+ if (exportRegistration) setSkillCatalogForExport(cached.skills);
115
127
  return cached.skills;
116
128
  });
117
129
  inflight = promise;
@@ -176,7 +188,8 @@ export function resetSkillContentTransport(): void {
176
188
  contentTransport = defaultContentTransport;
177
189
  }
178
190
 
179
- function normalizeSkillContent(raw: unknown): SkillExportContent | null {
191
+ /** A raw `{ content, truncated, dir, path }` record as an export content, or null when any part is missing. */
192
+ export function normalizeSkillContent(raw: unknown): SkillExportContent | null {
180
193
  if (!raw || typeof raw !== 'object') return null;
181
194
  const { content, truncated, dir, path } = raw as Record<string, unknown>;
182
195
  if (typeof content !== 'string' || !content) return null;