@plannotator/ui 0.22.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 (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,991 @@
1
+ import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types';
2
+ import { planDenyFeedback } from '@plannotator/core/feedback-templates';
3
+
4
+ /**
5
+ * Parsed YAML frontmatter as key-value pairs.
6
+ */
7
+ export interface Frontmatter {
8
+ [key: string]: string | string[];
9
+ }
10
+
11
+ /**
12
+ * Extract YAML frontmatter from markdown if present.
13
+ * Returns the parsed frontmatter, the remaining markdown, and the 1-based
14
+ * line number where content begins in the original file (so downstream
15
+ * line references stay accurate).
16
+ */
17
+ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter | null; content: string; contentStartLine: number } {
18
+ const trimmed = markdown.trimStart();
19
+ if (!trimmed.startsWith('---')) {
20
+ return { frontmatter: null, content: markdown, contentStartLine: 1 };
21
+ }
22
+
23
+ // Find the closing ---
24
+ const endIndex = trimmed.indexOf('\n---', 3);
25
+ if (endIndex === -1) {
26
+ return { frontmatter: null, content: markdown, contentStartLine: 1 };
27
+ }
28
+
29
+ // Extract frontmatter content (between the --- delimiters)
30
+ const frontmatterRaw = trimmed.slice(4, endIndex).trim();
31
+ const rawAfterFrontmatter = trimmed.slice(endIndex + 4);
32
+ const afterFrontmatter = rawAfterFrontmatter.trimStart();
33
+
34
+ // Compute the 1-based line where content begins in the original file.
35
+ // Account for: leading whitespace trimmed from original, the frontmatter
36
+ // block itself, and any blank lines between closing --- and first content.
37
+ const leadingChars = markdown.length - trimmed.length;
38
+ const consumedInTrimmed = endIndex + 4 + (rawAfterFrontmatter.length - afterFrontmatter.length);
39
+ const consumedTotal = leadingChars + consumedInTrimmed;
40
+ const contentStartLine = (markdown.slice(0, consumedTotal).match(/\n/g) || []).length + 1;
41
+
42
+ // Parse simple YAML (key: value pairs)
43
+ const frontmatter: Frontmatter = {};
44
+ let currentKey: string | null = null;
45
+ let currentArray: string[] | null = null;
46
+
47
+ for (const line of frontmatterRaw.split('\n')) {
48
+ const trimmedLine = line.trim();
49
+
50
+ // Array item (- value)
51
+ if (trimmedLine.startsWith('- ') && currentKey) {
52
+ const value = trimmedLine.slice(2).trim();
53
+ if (!currentArray) {
54
+ currentArray = [];
55
+ frontmatter[currentKey] = currentArray;
56
+ }
57
+ currentArray.push(value);
58
+ continue;
59
+ }
60
+
61
+ // Key: value pair
62
+ const colonIndex = trimmedLine.indexOf(':');
63
+ if (colonIndex > 0) {
64
+ currentKey = trimmedLine.slice(0, colonIndex).trim();
65
+ const value = trimmedLine.slice(colonIndex + 1).trim();
66
+ currentArray = null;
67
+
68
+ if (value) {
69
+ frontmatter[currentKey] = value;
70
+ }
71
+ }
72
+ }
73
+
74
+ return { frontmatter, content: afterFrontmatter, contentStartLine };
75
+ }
76
+
77
+ /**
78
+ * Tag names that trigger a raw HTML block per CommonMark §4.6, Type 6.
79
+ * A line starting with `<tag` or `</tag` (where `tag` is in this set) opens
80
+ * an HTML block that continues verbatim until a blank line or EOF.
81
+ *
82
+ * Inline-only tags (`kbd`, `sub`, `sup`, `mark`, etc.) are NOT here — a line
83
+ * that happens to start with one of those still goes through the paragraph
84
+ * path and renders as escaped text, matching prior behavior.
85
+ */
86
+ export const HTML_BLOCK_TAGS: ReadonlySet<string> = new Set([
87
+ 'details', 'summary',
88
+ 'div', 'section', 'article', 'aside', 'header', 'footer',
89
+ 'blockquote', 'pre',
90
+ 'table', 'thead', 'tbody', 'tr', 'td', 'th',
91
+ 'ul', 'ol', 'li', 'p',
92
+ // Media: GitHub embeds screenshots/videos as raw HTML on their own line.
93
+ 'img', 'video', 'picture',
94
+ ]);
95
+
96
+ /** Void elements — no closing tag, so the block is a single line (don't scan
97
+ * ahead for a `</tag>` that will never come). */
98
+ const VOID_HTML_TAGS: ReadonlySet<string> = new Set([
99
+ 'img', 'br', 'hr', 'source', 'input', 'wbr', 'area', 'col', 'embed',
100
+ ]);
101
+
102
+ const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s|>|\/|$)/;
103
+
104
+ /**
105
+ * A simplified markdown parser that splits content into linear blocks.
106
+ * For a production app, we would use a robust AST walker (remark),
107
+ * but for this demo, we want predictable text-anchoring.
108
+ */
109
+ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
110
+ const { content: cleanMarkdown, contentStartLine } = extractFrontmatter(markdown);
111
+ const lines = cleanMarkdown.split('\n');
112
+ const blocks: Block[] = [];
113
+ let currentId = 0;
114
+
115
+ let buffer: string[] = [];
116
+ let currentType: Block['type'] = 'paragraph';
117
+ let currentLevel = 0;
118
+ let bufferStartLine = contentStartLine;
119
+ let lastLineWasBlank = false;
120
+
121
+ const flush = () => {
122
+ if (buffer.length > 0) {
123
+ const content = buffer.join('\n');
124
+ blocks.push({
125
+ id: `block-${currentId++}`,
126
+ type: currentType,
127
+ content: content,
128
+ level: currentLevel,
129
+ order: currentId,
130
+ startLine: bufferStartLine
131
+ });
132
+ buffer = [];
133
+ }
134
+ };
135
+
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const line = lines[i];
138
+ const trimmed = line.trim();
139
+ const currentLineNum = i + contentStartLine;
140
+ const prevLineWasBlank = lastLineWasBlank;
141
+ lastLineWasBlank = false;
142
+
143
+ // Headings
144
+ if (trimmed.startsWith('#')) {
145
+ flush();
146
+ const level = trimmed.match(/^#+/)?.[0].length || 1;
147
+ blocks.push({
148
+ id: `block-${currentId++}`,
149
+ type: 'heading',
150
+ content: trimmed.replace(/^#+\s*/, ''),
151
+ level,
152
+ order: currentId,
153
+ startLine: currentLineNum
154
+ });
155
+ continue;
156
+ }
157
+
158
+ // Horizontal Rule
159
+ if (trimmed === '---' || trimmed === '***') {
160
+ flush();
161
+ blocks.push({
162
+ id: `block-${currentId++}`,
163
+ type: 'hr',
164
+ content: '',
165
+ order: currentId,
166
+ startLine: currentLineNum
167
+ });
168
+ continue;
169
+ }
170
+
171
+ // List Items (Simple detection)
172
+ const listMatch = trimmed.match(/^(\*|-|(\d+)\.)\s/);
173
+ if (listMatch) {
174
+ flush(); // Treat each list item as a separate block for easier annotation
175
+ // Calculate indentation level from leading whitespace
176
+ const leadingWhitespace = line.match(/^(\s*)/)?.[1] || '';
177
+ // Count spaces (2 spaces = 1 level) or tabs (1 tab = 1 level)
178
+ const spaceCount = leadingWhitespace.replace(/\t/g, ' ').length;
179
+ const listLevel = Math.floor(spaceCount / 2);
180
+
181
+ // Distinguish numeric markers (\d+.) from bullet markers (* / -)
182
+ const ordered = listMatch[2] !== undefined;
183
+ const orderedStart = ordered ? parseInt(listMatch[2]!, 10) : undefined;
184
+
185
+ // Remove list marker
186
+ let content = trimmed.slice(listMatch[0].length);
187
+
188
+ // Check for checkbox syntax: [ ] or [x] or [X]
189
+ let checked: boolean | undefined = undefined;
190
+ const checkboxMatch = content.match(/^\[([ xX])\]\s*/);
191
+ if (checkboxMatch) {
192
+ checked = checkboxMatch[1].toLowerCase() === 'x';
193
+ content = content.replace(/^\[([ xX])\]\s*/, '');
194
+ }
195
+
196
+ blocks.push({
197
+ id: `block-${currentId++}`,
198
+ type: 'list-item',
199
+ content,
200
+ level: listLevel,
201
+ checked,
202
+ ordered: ordered || undefined,
203
+ orderedStart,
204
+ order: currentId,
205
+ startLine: currentLineNum
206
+ });
207
+ continue;
208
+ }
209
+
210
+ // Blockquotes — consecutive `>` lines merge into one block so wrapped
211
+ // paragraph quotes render as a single continuous quote box. A blank line
212
+ // breaks the blockquote so the next `>` starts a fresh one.
213
+ //
214
+ // Exception: if the stripped content starts with a block-level marker
215
+ // (list item, heading, code fence, nested blockquote) we do NOT merge.
216
+ // Our flat block model can't render a list-inside-a-quote as an actual
217
+ // nested list, so merging would flatten the markers into run-on inline
218
+ // text. Leaving them as separate blockquote blocks preserves each line's
219
+ // visual identity (a stacked-box layout) — imperfect but legible. A
220
+ // proper recursive blockquote parser is tracked as a follow-up.
221
+ if (trimmed.startsWith('>')) {
222
+ flush();
223
+ const stripped = trimmed.replace(/^>\s*/, '');
224
+ // List markers require trailing whitespace to avoid matching inline
225
+ // text like "-hyphen" or "1.5 seconds"; headings, code fences, and
226
+ // nested blockquote markers don't require it (``` can be followed
227
+ // directly by a language tag, # can start a dense heading).
228
+ const blockMarkerRe = /^(?:(?:\*|-|\d+\.)\s|#|```|>)/;
229
+ const hasBlockMarker = blockMarkerRe.test(stripped);
230
+ const prevBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
231
+ // Don't merge into a previous blockquote whose content itself starts
232
+ // with a block marker — otherwise a `> some text` line following a
233
+ // `> 1. item` line would get glued onto the list-item block.
234
+ const prevIsMarkerQuote =
235
+ prevBlock?.type === 'blockquote' && blockMarkerRe.test(prevBlock.content);
236
+ // Alerts own their body: once a blockquote is tagged as an alert,
237
+ // subsequent `>` lines always merge into it (until a blank line).
238
+ // Without this, `> [!NOTE]\n> - item` splits the list item off into
239
+ // a separate plain quote, losing the callout.
240
+ const prevIsAlert = prevBlock?.type === 'blockquote' && !!prevBlock.alertKind;
241
+ const shouldMergeIntoAlert = prevIsAlert && !prevLineWasBlank;
242
+ const shouldMergeNormal =
243
+ !hasBlockMarker &&
244
+ !prevIsMarkerQuote &&
245
+ !prevLineWasBlank &&
246
+ prevBlock?.type === 'blockquote';
247
+ if (shouldMergeIntoAlert || shouldMergeNormal) {
248
+ prevBlock!.content = prevBlock!.content
249
+ ? prevBlock!.content + '\n' + stripped
250
+ : stripped;
251
+ } else {
252
+ // GitHub alert marker: a blockquote whose first line is [!KIND].
253
+ // We strip the marker from content and tag the block; rendering decides the style.
254
+ const alertMatch = stripped.match(/^\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/i);
255
+ blocks.push({
256
+ id: `block-${currentId++}`,
257
+ type: 'blockquote',
258
+ content: alertMatch ? '' : stripped,
259
+ alertKind: alertMatch
260
+ ? (alertMatch[1].toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important')
261
+ : undefined,
262
+ order: currentId,
263
+ startLine: currentLineNum
264
+ });
265
+ }
266
+ continue;
267
+ }
268
+
269
+ // Code blocks (naive)
270
+ if (trimmed.startsWith('```')) {
271
+ flush();
272
+ const codeStartLine = currentLineNum;
273
+ // Count backticks in opening fence to support nested fences (e.g. ```` wrapping ```)
274
+ const fenceLen = trimmed.match(/^`+/)?.[0].length ?? 3;
275
+ const closingFence = new RegExp('^\\s*`{' + fenceLen + ',}');
276
+ // Extract language from fence (e.g., ```rust → "rust")
277
+ const language = trimmed.slice(fenceLen).trim() || undefined;
278
+ // Fast forward until end of code block
279
+ let codeContent = [];
280
+ i++; // Skip start fence
281
+ while(i < lines.length && !closingFence.test(lines[i])) {
282
+ codeContent.push(lines[i]);
283
+ i++;
284
+ }
285
+ blocks.push({
286
+ id: `block-${currentId++}`,
287
+ type: 'code',
288
+ content: codeContent.join('\n'),
289
+ language,
290
+ order: currentId,
291
+ startLine: codeStartLine
292
+ });
293
+ continue;
294
+ }
295
+
296
+ // Display math: $$ ... $$ — only when a closing $$ actually exists. An
297
+ // unclosed $$ (a stray delimiter or informal money like "$$100k for infra")
298
+ // must NOT swallow the rest of the document: scan ahead without committing,
299
+ // and if there's no close, fall through and treat the line as ordinary text.
300
+ if (trimmed.startsWith('$$')) {
301
+ const mathStartLine = currentLineNum;
302
+ const afterOpen = trimmed.slice(2);
303
+ const mathLines: string[] = [];
304
+ let remainder = '';
305
+ let closed = false;
306
+ let closeLine = i;
307
+ // Find the closing $$ anywhere on the opening line (not just at its end),
308
+ // so `$$x$$.` or `$$x$$ trailing` close correctly instead of running on.
309
+ const inlineClose = afterOpen.indexOf('$$');
310
+ if (inlineClose !== -1) {
311
+ const body = afterOpen.slice(0, inlineClose).trim();
312
+ if (body) mathLines.push(body);
313
+ remainder = afterOpen.slice(inlineClose + 2).trim();
314
+ closed = true;
315
+ } else {
316
+ const scanned: string[] = afterOpen.trim() ? [afterOpen.trim()] : [];
317
+ let j = i;
318
+ while (j + 1 < lines.length) {
319
+ j++;
320
+ // A blank line ends the search: real $$…$$ has no blank line before its
321
+ // close, so a blank means this opener was never closed. Stopping here
322
+ // also prevents matching a stray $$ far below (e.g. inside a later code
323
+ // fence) and swallowing everything in between.
324
+ if (lines[j].trim() === '') break;
325
+ const closeAt = lines[j].indexOf('$$');
326
+ if (closeAt !== -1) {
327
+ const before = lines[j].slice(0, closeAt);
328
+ if (before.trim()) scanned.push(before);
329
+ remainder = lines[j].slice(closeAt + 2).trim();
330
+ closed = true;
331
+ closeLine = j;
332
+ break;
333
+ }
334
+ scanned.push(lines[j]);
335
+ }
336
+ if (closed) for (const s of scanned) mathLines.push(s);
337
+ }
338
+
339
+ if (closed) {
340
+ flush();
341
+ i = closeLine;
342
+ blocks.push({
343
+ id: `block-${currentId++}`,
344
+ type: 'math',
345
+ content: mathLines.join('\n'),
346
+ order: currentId,
347
+ startLine: mathStartLine,
348
+ sourceLineCount: i + contentStartLine - mathStartLine + 1,
349
+ });
350
+ // Trailing text after the closing $$ isn't math — reprocess it as its own
351
+ // line so it renders normally instead of being swallowed into the block.
352
+ if (remainder) {
353
+ lines[i] = remainder;
354
+ i--;
355
+ }
356
+ continue;
357
+ }
358
+ // No closing $$ found — not display math; fall through to normal handling.
359
+ }
360
+
361
+ // Display math: \[ ... \] — same unclosed-guard as $$ above: only treat as
362
+ // math when the closing \] exists, so a stray `\[deprecated\]`-style line or
363
+ // an unclosed `\[` doesn't swallow the rest of the document.
364
+ if (trimmed.startsWith('\\[')) {
365
+ const mathStartLine = currentLineNum;
366
+ const afterOpen = trimmed.slice(2);
367
+ const mathLines: string[] = [];
368
+ let remainder = '';
369
+ let closed = false;
370
+ let closeLine = i;
371
+ const inlineClose = afterOpen.indexOf('\\]');
372
+ if (inlineClose !== -1) {
373
+ const body = afterOpen.slice(0, inlineClose).trim();
374
+ if (body) mathLines.push(body);
375
+ remainder = afterOpen.slice(inlineClose + 2).trim();
376
+ closed = true;
377
+ } else {
378
+ const scanned: string[] = afterOpen.trim() ? [afterOpen.trim()] : [];
379
+ let j = i;
380
+ while (j + 1 < lines.length) {
381
+ j++;
382
+ // Blank line ends the search (see the $$ branch): unclosed \[ must not
383
+ // swallow later blocks or match a stray \] inside a later code fence.
384
+ if (lines[j].trim() === '') break;
385
+ const closeAt = lines[j].indexOf('\\]');
386
+ if (closeAt !== -1) {
387
+ const before = lines[j].slice(0, closeAt);
388
+ if (before.trim()) scanned.push(before);
389
+ remainder = lines[j].slice(closeAt + 2).trim();
390
+ closed = true;
391
+ closeLine = j;
392
+ break;
393
+ }
394
+ scanned.push(lines[j]);
395
+ }
396
+ if (closed) for (const s of scanned) mathLines.push(s);
397
+ }
398
+
399
+ if (closed) {
400
+ flush();
401
+ i = closeLine;
402
+ blocks.push({
403
+ id: `block-${currentId++}`,
404
+ type: 'math',
405
+ content: mathLines.join('\n'),
406
+ order: currentId,
407
+ startLine: mathStartLine,
408
+ sourceLineCount: i + contentStartLine - mathStartLine + 1,
409
+ });
410
+ if (remainder) {
411
+ lines[i] = remainder;
412
+ i--;
413
+ }
414
+ continue;
415
+ }
416
+ // No closing \] found — not display math; fall through to normal handling.
417
+ }
418
+
419
+ // Tables (lines starting with |)
420
+ if (trimmed.startsWith('|')) {
421
+ flush();
422
+ const tableStartLine = currentLineNum;
423
+ const tableLines: string[] = [line];
424
+
425
+ // Collect all consecutive table lines
426
+ while (i + 1 < lines.length) {
427
+ const nextLine = lines[i + 1].trim();
428
+ // Continue if line starts with | (table row or separator)
429
+ if (nextLine.startsWith('|')) {
430
+ i++;
431
+ tableLines.push(lines[i]);
432
+ } else {
433
+ break;
434
+ }
435
+ }
436
+
437
+ blocks.push({
438
+ id: `block-${currentId++}`,
439
+ type: 'table',
440
+ content: tableLines.join('\n'),
441
+ order: currentId,
442
+ startLine: tableStartLine
443
+ });
444
+ continue;
445
+ }
446
+
447
+ // Raw HTML blocks. A line starting with a known block-level HTML tag
448
+ // opens an HTML block. For opening tags we accumulate until the matching
449
+ // close tag is balanced (so `<details>…blank line…</details>` renders as
450
+ // one unit, matching GitHub's flavored behavior rather than strict
451
+ // CommonMark §4.6 Type 6 blank-line termination). For a line that starts
452
+ // with a close tag, we fall back to blank-line termination. Content is
453
+ // sanitized at render time, not here.
454
+ // Directive container: `:::kind` opens, `:::` closes. Inline kind is
455
+ // restricted to simple identifiers (letters, digits, hyphens). Body is
456
+ // accumulated verbatim and rendered with inline markdown.
457
+ const directiveOpen = trimmed.match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\s*$/);
458
+ if (directiveOpen) {
459
+ flush();
460
+ const directiveStartLine = currentLineNum;
461
+ const kind = directiveOpen[1].toLowerCase();
462
+ const bodyLines: string[] = [];
463
+ while (i + 1 < lines.length) {
464
+ i++;
465
+ if (lines[i].trim() === ':::') break;
466
+ bodyLines.push(lines[i]);
467
+ }
468
+ blocks.push({
469
+ id: `block-${currentId++}`,
470
+ type: 'directive',
471
+ content: bodyLines.join('\n'),
472
+ directiveKind: kind,
473
+ order: currentId,
474
+ startLine: directiveStartLine,
475
+ });
476
+ continue;
477
+ }
478
+
479
+ const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE);
480
+ if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) {
481
+ flush();
482
+ const htmlStartLine = currentLineNum;
483
+ const tagName = htmlTagMatch[1].toLowerCase();
484
+ const isCloseTag = trimmed.startsWith('</');
485
+ const htmlLines: string[] = [line];
486
+
487
+ if (isCloseTag) {
488
+ while (i + 1 < lines.length && lines[i + 1].trim() !== '') {
489
+ i++;
490
+ htmlLines.push(lines[i]);
491
+ }
492
+ } else if (VOID_HTML_TAGS.has(tagName)) {
493
+ // Void element (e.g. <img>): no closing tag, but attributes can wrap across
494
+ // lines. Consume until the line that actually closes the tag with `>` so a
495
+ // multi-line <img> isn't truncated to a bare `<img` fragment.
496
+ while (!lines[i].includes('>') && i + 1 < lines.length && lines[i + 1].trim() !== '') {
497
+ i++;
498
+ htmlLines.push(lines[i]);
499
+ }
500
+ } else {
501
+ const openRe = new RegExp(`<${tagName}(?:\\s|>|/|$)`, 'gi');
502
+ const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
503
+ 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
+ }
521
+ }
522
+ }
523
+
524
+ blocks.push({
525
+ id: `block-${currentId++}`,
526
+ type: 'html',
527
+ content: htmlLines.join('\n'),
528
+ order: currentId,
529
+ startLine: htmlStartLine,
530
+ });
531
+ continue;
532
+ }
533
+
534
+ // Empty lines separate paragraphs
535
+ if (trimmed === '') {
536
+ flush();
537
+ currentType = 'paragraph';
538
+ lastLineWasBlank = true;
539
+ continue;
540
+ }
541
+ // List continuation: indented line after a list item merges into it.
542
+ // Tight (no blank line): 1+ whitespace, joined with \n (same paragraph).
543
+ // Loose (after blank line): 2+ spaces, joined with \n\n (new paragraph within the item).
544
+ if (
545
+ buffer.length === 0 &&
546
+ blocks.length > 0 &&
547
+ blocks[blocks.length - 1].type === 'list-item' &&
548
+ (prevLineWasBlank ? /^\s{2,}/ : /^\s+/).test(line)
549
+ ) {
550
+ const sep = prevLineWasBlank ? '\n\n' : '\n';
551
+ blocks[blocks.length - 1].content += sep + trimmed;
552
+ continue;
553
+ }
554
+
555
+ // Accumulate paragraph text
556
+ if (buffer.length === 0) {
557
+ bufferStartLine = currentLineNum;
558
+ }
559
+ buffer.push(line);
560
+ }
561
+
562
+ flush(); // Final flush
563
+
564
+ return blocks;
565
+ };
566
+
567
+ /**
568
+ * Compute the display index for each list item in a contiguous list group.
569
+ *
570
+ * Returns a parallel array where each entry is either:
571
+ * - a positive integer (the numeral to render for an ordered item), or
572
+ * - null (the item is unordered, render a bullet symbol).
573
+ *
574
+ * Semantics:
575
+ * - A run of consecutive ordered items at the same level increments
576
+ * sequentially. The first item in a run uses its `orderedStart` (the
577
+ * number from the source markdown); subsequent items renumber from there
578
+ * so `1. / 2. / 5.` renders as 1, 2, 3 (matches CommonMark).
579
+ * - An unordered item at level L breaks the ordered streak at L. The next
580
+ * ordered item at L restarts from its own `orderedStart`.
581
+ * - Visiting a level shallower than the current one truncates deeper-level
582
+ * state, so re-entering that depth later starts fresh. Top-level numbering
583
+ * continues across nested children of any kind.
584
+ */
585
+ export const computeListIndices = (blocks: Block[]): (number | null)[] => {
586
+ const counters: number[] = [];
587
+ const lastOrderedAtLevel: boolean[] = [];
588
+
589
+ return blocks.map(block => {
590
+ const lvl = block.level || 0;
591
+ // Sibling change at any deeper level resets those levels.
592
+ counters.length = lvl + 1;
593
+ lastOrderedAtLevel.length = lvl + 1;
594
+
595
+ if (!block.ordered) {
596
+ lastOrderedAtLevel[lvl] = false;
597
+ return null;
598
+ }
599
+
600
+ if (lastOrderedAtLevel[lvl]) {
601
+ counters[lvl] = (counters[lvl] ?? 0) + 1;
602
+ } else {
603
+ counters[lvl] = block.orderedStart ?? 1;
604
+ }
605
+ lastOrderedAtLevel[lvl] = true;
606
+ return counters[lvl];
607
+ });
608
+ };
609
+
610
+ /** A run of blocks to render: a single block, or consecutive list items grouped
611
+ * together so list numbering/indent can be computed across the run. */
612
+ export type RenderGroup =
613
+ | { type: 'single'; block: Block }
614
+ | { type: 'list-group'; blocks: Block[]; key: string };
615
+
616
+ /** Groups consecutive list-item blocks so a list renders as one unit. */
617
+ export function groupBlocks(blocks: Block[]): RenderGroup[] {
618
+ const groups: RenderGroup[] = [];
619
+ let i = 0;
620
+ while (i < blocks.length) {
621
+ if (blocks[i].type === 'list-item') {
622
+ const listBlocks: Block[] = [];
623
+ while (i < blocks.length && blocks[i].type === 'list-item') {
624
+ listBlocks.push(blocks[i]);
625
+ i++;
626
+ }
627
+ groups.push({ type: 'list-group', blocks: listBlocks, key: `list-${listBlocks[0].id}` });
628
+ } else {
629
+ groups.push({ type: 'single', block: blocks[i] });
630
+ i++;
631
+ }
632
+ }
633
+ return groups;
634
+ }
635
+
636
+ /** Wrap feedback output with the deny preamble for pasting into agent sessions */
637
+ export const wrapFeedbackForAgent = (feedback: string): string =>
638
+ planDenyFeedback(feedback);
639
+
640
+ export interface ExportAnnotationsOptions {
641
+ sourceConverted?: boolean;
642
+ }
643
+
644
+ /** Compute the end line of a block from its content and type. */
645
+ const blockEndLine = (block: Block): number => {
646
+ if (block.sourceLineCount && block.sourceLineCount > 0) {
647
+ return block.startLine + block.sourceLineCount - 1;
648
+ }
649
+ if (!block.content) return block.startLine;
650
+ const contentLines = block.content.split('\n').length;
651
+ if (block.type === 'code') return block.startLine + contentLines + 1;
652
+ if (block.type === 'directive') return block.startLine + contentLines + 1;
653
+ if (block.alertKind) return block.startLine + contentLines;
654
+ return block.startLine + contentLines - 1;
655
+ };
656
+
657
+ /** Resolve the source-line label for a single annotation.
658
+ * Returns null for global comments, diff-view annotations, or missing blocks. */
659
+ const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
660
+ if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
661
+ if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
662
+ const block = blocks.find(b => b.id === ann.blockId);
663
+ if (!block || typeof block.startLine !== 'number') return null;
664
+ const end = blockEndLine(block);
665
+ if (end <= block.startLine) return `line ${block.startLine}`;
666
+ return `lines ${block.startLine}–${end}`;
667
+ };
668
+
669
+ export const exportAnnotations = (
670
+ blocks: Block[],
671
+ annotations: any[],
672
+ globalAttachments: ImageAttachment[] = [],
673
+ title: string = 'Plan Feedback',
674
+ subject: string = 'plan',
675
+ opts: ExportAnnotationsOptions = {},
676
+ ): string => {
677
+ if (annotations.length === 0 && globalAttachments.length === 0) {
678
+ return 'No changes detected.';
679
+ }
680
+
681
+ // Sort annotations by block and offset
682
+ const sortedAnns = [...annotations].sort((a, b) => {
683
+ const blockA = blocks.findIndex(blk => blk.id === a.blockId);
684
+ const blockB = blocks.findIndex(blk => blk.id === b.blockId);
685
+ if (blockA !== blockB) return blockA - blockB;
686
+ return a.startOffset - b.startOffset;
687
+ });
688
+
689
+ let output = `# ${title}\n\n`;
690
+
691
+ if (opts.sourceConverted) {
692
+ output += `> Note: Line numbers below refer to the converted markdown, not the original HTML/URL source.\n\n`;
693
+ }
694
+
695
+ // Add global reference images section if any
696
+ if (globalAttachments.length > 0) {
697
+ output += `## Reference Images\n`;
698
+ output += `Please review these reference images (use the Read tool to view):\n`;
699
+ globalAttachments.forEach((img, idx) => {
700
+ output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`;
701
+ });
702
+ output += `\n`;
703
+ }
704
+
705
+ if (annotations.length > 0) {
706
+ output += `I've reviewed this ${subject} and have ${annotations.length} piece${annotations.length > 1 ? 's' : ''} of feedback:\n\n`;
707
+ }
708
+
709
+ sortedAnns.forEach((ann, index) => {
710
+ output += `## ${index + 1}. `;
711
+
712
+ // Add diff context label if annotation was created in diff view
713
+ if (ann.diffContext) {
714
+ output += `[In diff content] `;
715
+ } else {
716
+ const lineLabel = lineLabelForAnnotation(blocks, ann);
717
+ if (lineLabel) output += `(${lineLabel}) `;
718
+ }
719
+
720
+ switch (ann.type) {
721
+ case 'DELETION':
722
+ output += `Remove this\n`;
723
+ output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`;
724
+ output += `> I don't want this in the ${subject}.\n`;
725
+ break;
726
+
727
+ case 'COMMENT':
728
+ if (ann.isQuickLabel) {
729
+ output += `[${ann.text}] Feedback on: "${ann.originalText}"\n`;
730
+ if (ann.quickLabelTip) {
731
+ output += `> ${ann.quickLabelTip}\n`;
732
+ }
733
+ } else {
734
+ output += `Feedback on: "${ann.originalText}"\n`;
735
+ output += `> ${ann.text}\n`;
736
+ }
737
+ break;
738
+
739
+ case 'GLOBAL_COMMENT':
740
+ output += `General feedback about the ${subject}\n`;
741
+ output += `> ${ann.text}\n`;
742
+ break;
743
+ }
744
+
745
+ // Add attached images for this annotation
746
+ if (ann.images && ann.images.length > 0) {
747
+ output += `**Attached images:**\n`;
748
+ ann.images.forEach((img: ImageAttachment) => {
749
+ output += `- [${img.name}] \`${img.path}\`\n`;
750
+ });
751
+ }
752
+
753
+ output += '\n';
754
+ });
755
+
756
+ output += `---\n`;
757
+
758
+ // Quick Label Summary
759
+ const labeledAnns = sortedAnns.filter((a: any) => a.isQuickLabel && a.text);
760
+ if (labeledAnns.length > 0) {
761
+ const grouped = new Map<string, number>();
762
+ labeledAnns.forEach((a: any) => {
763
+ grouped.set(a.text, (grouped.get(a.text) || 0) + 1);
764
+ });
765
+
766
+ output += `\n## Label Summary\n\n`;
767
+ for (const [text, count] of grouped) {
768
+ output += `- **${text}**: ${count}\n`;
769
+ }
770
+ output += '\n';
771
+ }
772
+
773
+ return output;
774
+ };
775
+
776
+ export interface LinkedDocAnnotationEntry {
777
+ annotations: Annotation[];
778
+ globalAttachments: ImageAttachment[];
779
+ markdown?: string;
780
+ blocks?: Block[];
781
+ isConverted?: boolean;
782
+ }
783
+
784
+ export const exportLinkedDocAnnotations = (
785
+ docAnnotations: Map<string, LinkedDocAnnotationEntry>
786
+ ): string => {
787
+ let output = `\n# Linked Document Feedback\n\nThe following feedback is on documents referenced in the plan.\n\n`;
788
+
789
+ for (const [filepath, { annotations, globalAttachments, blocks: docBlocks, isConverted }] of docAnnotations) {
790
+ if (annotations.length === 0 && globalAttachments.length === 0) continue;
791
+
792
+ output += `## ${filepath}${isConverted ? ' (converted from HTML — line numbers refer to converted markdown)' : ''}\n\n`;
793
+
794
+ if (globalAttachments.length > 0) {
795
+ output += `### Reference Images\n`;
796
+ output += `Please review these reference images (use the Read tool to view):\n`;
797
+ globalAttachments.forEach((img, idx) => {
798
+ output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`;
799
+ });
800
+ output += `\n`;
801
+ }
802
+
803
+ // Sort annotations by block and offset
804
+ const sortedAnns = [...annotations].sort((a, b) => {
805
+ if (a.blockId !== b.blockId) return a.blockId.localeCompare(b.blockId);
806
+ return a.startOffset - b.startOffset;
807
+ });
808
+
809
+ output += `I've reviewed this document and have ${annotations.length} piece${annotations.length !== 1 ? 's' : ''} of feedback:\n\n`;
810
+
811
+ sortedAnns.forEach((ann, index) => {
812
+ output += `### ${index + 1}. `;
813
+
814
+ const lineLabel = docBlocks ? lineLabelForAnnotation(docBlocks, ann) : null;
815
+ if (lineLabel) output += `(${lineLabel}) `;
816
+
817
+ switch (ann.type) {
818
+ case 'DELETION':
819
+ output += `Remove this\n`;
820
+ output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`;
821
+ output += `> I don't want this in the document.\n`;
822
+ break;
823
+
824
+ case 'COMMENT':
825
+ output += `Feedback on: "${ann.originalText}"\n`;
826
+ output += `> ${ann.text}\n`;
827
+ break;
828
+
829
+ case 'GLOBAL_COMMENT':
830
+ output += `General feedback about the document\n`;
831
+ output += `> ${ann.text}\n`;
832
+ break;
833
+ }
834
+
835
+ if (ann.images && ann.images.length > 0) {
836
+ output += `**Attached images:**\n`;
837
+ ann.images.forEach((img: ImageAttachment) => {
838
+ output += `- [${img.name}] \`${img.path}\`\n`;
839
+ });
840
+ }
841
+
842
+ output += '\n';
843
+ });
844
+ }
845
+
846
+ output += `---\n`;
847
+ return output;
848
+ };
849
+
850
+ export const exportEditorAnnotations = (editorAnnotations: EditorAnnotation[]): string => {
851
+ if (editorAnnotations.length === 0) return '';
852
+
853
+ let output = `\n# Editor File Annotations\n\nThe following annotations reference code files in the project.\n\n`;
854
+
855
+ editorAnnotations.forEach((ann, index) => {
856
+ const lineRange = ann.lineStart === ann.lineEnd
857
+ ? `line ${ann.lineStart}`
858
+ : `lines ${ann.lineStart}-${ann.lineEnd}`;
859
+
860
+ output += `## ${index + 1}. ${ann.filePath} (${lineRange})\n`;
861
+ output += `\`\`\`\n${ann.selectedText}\n\`\`\`\n`;
862
+
863
+ if (ann.comment) {
864
+ output += `> ${ann.comment}\n`;
865
+ }
866
+
867
+ output += '\n';
868
+ });
869
+
870
+ output += `---\n`;
871
+ return output;
872
+ };
873
+
874
+ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string => {
875
+ if (annotations.length === 0) return '';
876
+
877
+ let output = `\n# Code File Feedback\n\nThe following feedback is on code files referenced from the reviewed document.\n\n`;
878
+ const sorted = [...annotations].sort((a, b) => {
879
+ if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath);
880
+ if (a.lineStart !== b.lineStart) return a.lineStart - b.lineStart;
881
+ return a.createdAt - b.createdAt;
882
+ });
883
+
884
+ sorted.forEach((ann, index) => {
885
+ const lineRange = ann.lineStart === ann.lineEnd
886
+ ? `line ${ann.lineStart}`
887
+ : `lines ${ann.lineStart}-${ann.lineEnd}`;
888
+
889
+ output += `## ${index + 1}. ${ann.filePath} (${lineRange})\n`;
890
+ if (ann.originalCode) {
891
+ output += `\`\`\`\n${ann.originalCode}\n\`\`\`\n`;
892
+ }
893
+ if (ann.text) {
894
+ output += `> ${ann.text}\n`;
895
+ }
896
+ if (ann.images && ann.images.length > 0) {
897
+ output += `**Attached images:**\n`;
898
+ ann.images.forEach((img) => {
899
+ output += `- [${img.name}] \`${img.path}\`\n`;
900
+ });
901
+ }
902
+ output += '\n';
903
+ });
904
+
905
+ output += `---\n`;
906
+ return output;
907
+ };
908
+
909
+ export interface MessageAnnotationEntry {
910
+ messageId: string;
911
+ text: string;
912
+ timestamp?: string;
913
+ annotations: Annotation[];
914
+ globalAttachments: ImageAttachment[];
915
+ blocks?: Block[];
916
+ linkedDocs?: Map<string, LinkedDocAnnotationEntry>;
917
+ codeAnnotations?: CodeAnnotation[];
918
+ }
919
+
920
+ const MESSAGE_EXCERPT_MAX_CHARS = 1200;
921
+
922
+ const excerptMessageText = (text: string): string => {
923
+ const trimmed = text.trim();
924
+ if (trimmed.length <= MESSAGE_EXCERPT_MAX_CHARS) return trimmed;
925
+ return `${trimmed.slice(0, MESSAGE_EXCERPT_MAX_CHARS).trimEnd()}...`;
926
+ };
927
+
928
+ const fencedBlock = (text: string, language = ''): string => {
929
+ let fence = '```';
930
+ while (text.includes(fence)) fence += '`';
931
+ return `${fence}${language}\n${text}\n${fence}\n`;
932
+ };
933
+
934
+ export const exportMessageAnnotations = (entries: MessageAnnotationEntry[]): string => {
935
+ const nonEmpty = entries.filter((entry) => {
936
+ const linkedDocCount = entry.linkedDocs
937
+ ? Array.from(entry.linkedDocs.values()).reduce(
938
+ (sum, doc) => sum + doc.annotations.length + doc.globalAttachments.length,
939
+ 0
940
+ )
941
+ : 0;
942
+ return (
943
+ entry.annotations.length > 0 ||
944
+ entry.globalAttachments.length > 0 ||
945
+ (entry.codeAnnotations?.length ?? 0) > 0 ||
946
+ linkedDocCount > 0
947
+ );
948
+ });
949
+
950
+ if (nonEmpty.length === 0) {
951
+ return 'User reviewed the messages and has no feedback.';
952
+ }
953
+
954
+ let output = `# Message Feedback\n\nThe following feedback spans ${nonEmpty.length} assistant message${nonEmpty.length === 1 ? '' : 's'}. Each section includes an excerpt of the message it applies to.\n\n`;
955
+
956
+ nonEmpty.forEach((entry, index) => {
957
+ const label = entry.timestamp ? ` (${entry.timestamp})` : '';
958
+ output += `## Message ${index + 1}${label}\n\n`;
959
+ output += `Message excerpt:\n`;
960
+ output += fencedBlock(excerptMessageText(entry.text), 'markdown');
961
+ output += '\n';
962
+
963
+ if (entry.annotations.length > 0 || entry.globalAttachments.length > 0) {
964
+ output += exportAnnotations(
965
+ entry.blocks ?? parseMarkdownToBlocks(entry.text),
966
+ entry.annotations,
967
+ entry.globalAttachments,
968
+ `Feedback for Message ${index + 1}`,
969
+ 'message',
970
+ );
971
+ output += '\n';
972
+ }
973
+
974
+ const hasLinkedDocFeedback = entry.linkedDocs
975
+ ? Array.from(entry.linkedDocs.values()).some(
976
+ (doc) => doc.annotations.length > 0 || doc.globalAttachments.length > 0
977
+ )
978
+ : false;
979
+ if (entry.linkedDocs && hasLinkedDocFeedback) {
980
+ output += exportLinkedDocAnnotations(entry.linkedDocs);
981
+ output += '\n';
982
+ }
983
+
984
+ if (entry.codeAnnotations?.length) {
985
+ output += exportCodeFileAnnotations(entry.codeAnnotations);
986
+ output += '\n';
987
+ }
988
+ });
989
+
990
+ return output.trimEnd();
991
+ };