@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,590 @@
1
+ /**
2
+ * Plan Diff Engine
3
+ *
4
+ * Computes line-level diffs between two plan versions, then (for modified
5
+ * blocks that qualify) computes a second-pass word-level diff so the UI
6
+ * can render inline insertions/deletions in context instead of showing
7
+ * the whole old block struck-through above the whole new block.
8
+ *
9
+ * Two-pass hierarchical diff: `diffLines` outer + `diffWordsWithSpace`
10
+ * inner, same shape as `git diff --word-diff`.
11
+ */
12
+
13
+ import { diffLines, diffWordsWithSpace, type Change } from "diff";
14
+ import { parseMarkdownToBlocks } from "./parser";
15
+ import type { Block } from "../types";
16
+
17
+ export interface InlineDiffToken {
18
+ type: "added" | "removed" | "unchanged";
19
+ value: string;
20
+ }
21
+
22
+ export interface InlineDiffWrap {
23
+ type: "heading" | "paragraph" | "list-item";
24
+ /** For headings */
25
+ level?: number;
26
+ /** For list items */
27
+ ordered?: boolean;
28
+ listLevel?: number;
29
+ checked?: boolean;
30
+ orderedStart?: number;
31
+ }
32
+
33
+ export interface PlanDiffBlock {
34
+ /** What kind of change this block represents */
35
+ type: "added" | "removed" | "modified" | "unchanged";
36
+ /** The content for this block (new content for added/modified, old content for removed, full content for unchanged) */
37
+ content: string;
38
+ /** For 'modified' blocks: the old content that was replaced */
39
+ oldContent?: string;
40
+ /** Number of lines in this block */
41
+ lines: number;
42
+ /** Present only on 'modified' blocks that pass the qualification gate for word-level inline diff. */
43
+ inlineTokens?: InlineDiffToken[];
44
+ /** Structural wrap metadata paired with inlineTokens. */
45
+ inlineWrap?: InlineDiffWrap;
46
+ }
47
+
48
+ export interface PlanDiffStats {
49
+ additions: number;
50
+ deletions: number;
51
+ modifications: number;
52
+ }
53
+
54
+ /**
55
+ * Count lines in a string (handles trailing newline correctly).
56
+ */
57
+ function countLines(text: string): number {
58
+ const lines = text.split("\n");
59
+ // diffLines often includes a trailing empty string from the final newline
60
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
61
+ return lines.length - 1;
62
+ }
63
+ return lines.length;
64
+ }
65
+
66
+ const INLINE_DIFFABLE_TYPES = new Set<Block["type"]>([
67
+ "paragraph",
68
+ "heading",
69
+ "list-item",
70
+ ]);
71
+
72
+ function structuralFieldsMatch(a: Block, b: Block): boolean {
73
+ if (a.type !== b.type) return false;
74
+ if (a.type === "heading") return a.level === b.level;
75
+ if (a.type === "list-item") {
76
+ return (
77
+ a.ordered === b.ordered &&
78
+ a.level === b.level &&
79
+ a.checked === b.checked
80
+ );
81
+ }
82
+ return true; // paragraph
83
+ }
84
+
85
+ // Sentinel used to replace inline-code spans before word-diffing. Must be
86
+ // made entirely of word characters ([A-Za-z0-9_]) so diffWordsWithSpace
87
+ // treats it as a single atomic token — word-boundary splits (\b) happen at
88
+ // transitions between word and non-word chars, so any non-word character
89
+ // inside the sentinel would cause the tokenizer to fragment it mid-diff
90
+ // and defeat the round-trip. Collision with real plan text is implausible.
91
+ const SENTINEL_PREFIX = "__PLDIFFCODE";
92
+ const SENTINEL_SUFFIX = "PLDIFFCODE__";
93
+ const SENTINEL_PATTERN = /__PLDIFFCODE\d+PLDIFFCODE__/g;
94
+ const CODE_SPAN_PATTERN = /`[^`]+`/g;
95
+
96
+ /**
97
+ * Replace every inline-code span in `text` with a numeric sentinel so that
98
+ * diffWordsWithSpace treats the span as an atomic token. Identical spans on
99
+ * both sides share the same sentinel, so they pair as unchanged. Different
100
+ * spans get different sentinels and diff as whole-span add/remove tokens.
101
+ *
102
+ * The sentinel sits in place of the backticks, so diff markers injected
103
+ * later by the unified-string builder never land *between* backticks — which
104
+ * is the hazard the old backtick gate was protecting against.
105
+ */
106
+ function sentinelFor(id: number): string {
107
+ return `${SENTINEL_PREFIX}${id}${SENTINEL_SUFFIX}`;
108
+ }
109
+
110
+ function substituteCodeSpans(
111
+ text: string,
112
+ codeMap: Map<string, string>,
113
+ codeToId: Map<string, number>
114
+ ): string {
115
+ return text.replace(CODE_SPAN_PATTERN, (match) => {
116
+ let id = codeToId.get(match);
117
+ if (id === undefined) {
118
+ id = codeToId.size;
119
+ codeMap.set(sentinelFor(id), match);
120
+ codeToId.set(match, id);
121
+ }
122
+ return sentinelFor(id);
123
+ });
124
+ }
125
+
126
+ function restoreCodeSpans(
127
+ value: string,
128
+ codeMap: Map<string, string>
129
+ ): string {
130
+ if (codeMap.size === 0) return value;
131
+ return value.replace(SENTINEL_PATTERN, (m) => codeMap.get(m) ?? m);
132
+ }
133
+
134
+ // Link sentinel. Same trick as inline-code spans but applied to markdown
135
+ // links [text](url) so diffWordsWithSpace treats each whole link atomically.
136
+ // Without this pass, a URL-only change like [docs](old) → [docs](new) would
137
+ // tokenize on "old"/"new" and inject <ins>/<del> markers into the raw
138
+ // Markdown, producing `[docs](https://<del>old</del><ins>new</ins>.example)`.
139
+ // InlineMarkdown's link regex would then swallow those tags into the href,
140
+ // rendering an unchanged-looking link with a broken URL. Atomizing the link
141
+ // means URL-only changes render as old-link-struck + new-link-green, each
142
+ // rendered as a real, clickable anchor. Tradeoff: word-level highlighting
143
+ // inside link anchor text goes away — the whole link is the diff unit.
144
+ const LINK_SENTINEL_PREFIX = "__PLDIFFLINK";
145
+ const LINK_SENTINEL_SUFFIX = "PLDIFFLINK__";
146
+ const LINK_SENTINEL_PATTERN = /__PLDIFFLINK\d+PLDIFFLINK__/g;
147
+ const LINK_PATTERN = /\[[^\]]+\]\([^)]+\)/g;
148
+
149
+ function linkSentinelFor(id: number): string {
150
+ return `${LINK_SENTINEL_PREFIX}${id}${LINK_SENTINEL_SUFFIX}`;
151
+ }
152
+
153
+ function substituteLinks(
154
+ text: string,
155
+ linkMap: Map<string, string>,
156
+ linkToId: Map<string, number>
157
+ ): string {
158
+ return text.replace(LINK_PATTERN, (match) => {
159
+ let id = linkToId.get(match);
160
+ if (id === undefined) {
161
+ id = linkToId.size;
162
+ linkMap.set(linkSentinelFor(id), match);
163
+ linkToId.set(match, id);
164
+ }
165
+ return linkSentinelFor(id);
166
+ });
167
+ }
168
+
169
+ function restoreLinks(
170
+ value: string,
171
+ linkMap: Map<string, string>
172
+ ): string {
173
+ if (linkMap.size === 0) return value;
174
+ return value.replace(LINK_SENTINEL_PATTERN, (m) => linkMap.get(m) ?? m);
175
+ }
176
+
177
+ // Fenced-block sentinel. Same idea as the inline-code sentinel above, but
178
+ // applied at block granularity BEFORE diffLines runs. Without this pass,
179
+ // diffLines finds common lines like the closing ```, a shared `}`, or a
180
+ // blank line between two otherwise-rewritten code blocks, and fragments
181
+ // the block into 4-6 separate diff chunks. Each chunk then renders
182
+ // independently (half a fence here, a stray `}` paragraph there, an
183
+ // empty <pre> where the lone closing ``` landed) — the visually "messy"
184
+ // cascade after case ⑩. By collapsing each whole fenced block to a
185
+ // single-line sentinel, diffLines treats the block atomically: it
186
+ // becomes one modified pair, rendered as a clean before/after.
187
+ // Capture the opening fence's backtick count and back-reference it on the
188
+ // closer so nested fences (e.g., 4-backtick outer wrapping a 3-backtick
189
+ // example) are matched atomically — the inner closer has fewer backticks
190
+ // than \1 and is correctly skipped by the lazy content scanner.
191
+ const FENCE_SENTINEL_PREFIX = "__PLDIFFFENCE";
192
+ const FENCE_SENTINEL_SUFFIX = "PLDIFFFENCE__";
193
+ const FENCE_SENTINEL_PATTERN = /__PLDIFFFENCE\d+PLDIFFFENCE__/g;
194
+ const FENCE_BLOCK_PATTERN = /^(`{3,})[^\n]*\n[\s\S]*?^\1[ \t]*$/gm;
195
+
196
+ function fenceSentinelFor(id: number): string {
197
+ return `${FENCE_SENTINEL_PREFIX}${id}${FENCE_SENTINEL_SUFFIX}`;
198
+ }
199
+
200
+ function substituteFencedBlocks(
201
+ text: string,
202
+ fenceMap: Map<string, string>,
203
+ fenceToId: Map<string, number>
204
+ ): string {
205
+ return text.replace(FENCE_BLOCK_PATTERN, (match) => {
206
+ let id = fenceToId.get(match);
207
+ if (id === undefined) {
208
+ id = fenceToId.size;
209
+ fenceMap.set(fenceSentinelFor(id), match);
210
+ fenceToId.set(match, id);
211
+ }
212
+ return fenceSentinelFor(id);
213
+ });
214
+ }
215
+
216
+ function restoreFencedBlocks(
217
+ value: string,
218
+ fenceMap: Map<string, string>
219
+ ): string {
220
+ if (fenceMap.size === 0) return value;
221
+ return value.replace(FENCE_SENTINEL_PATTERN, (m) => fenceMap.get(m) ?? m);
222
+ }
223
+
224
+ // Inline-emphasis sentinels. Each balanced markdown emphasis pair
225
+ // (`**...**`, `__...__`, `~~...~~`, `*...*`, `_..._`) gets a unique
226
+ // word-char sentinel before `diffWordsWithSpace` runs so that:
227
+ //
228
+ // - The whole phrase diffs as a single atomic token (sentinels contain
229
+ // no whitespace, and `diffWordsWithSpace` splits on whitespace).
230
+ // - Identical phrases on both sides pair as unchanged via sentinel
231
+ // identity; different phrases pair as a single remove + add.
232
+ //
233
+ // This is the "pair" form of atomization discussed in the bold-phrase
234
+ // design doc: it reliably handles both single-word (`**important**`) and
235
+ // multi-word (`**preliminary analysis**`) emphasis without fragmenting
236
+ // the closing delimiter into unchanged-tail tokens, which the earlier
237
+ // single-delimiter substitution couldn't achieve.
238
+ //
239
+ // Pair matching uses CommonMark-ish flanking rules: opening delimiter
240
+ // must not be preceded by a word char and must be followed by a non-space
241
+ // char; closing delimiter must be preceded by a non-space char and not
242
+ // followed by a word char. Content is matched lazily. Stray unbalanced
243
+ // delimiters (e.g., `2**3` arithmetic) don't match and are left verbatim,
244
+ // preserving existing behaviour.
245
+ //
246
+ // Longest-first ordering (`**`/`__`/`~~` before `*`/`_`) prevents single
247
+ // delimiters from eating the inside of a double-delim pair.
248
+ const PAIR_SENTINEL_PREFIX = "PLDIFFEMPH";
249
+ const PAIR_SENTINEL_PATTERN = /PLDIFFEMPH\d+ZZ/g;
250
+
251
+ const PAIR_PATTERNS: Array<{ delim: string; regex: RegExp }> = [
252
+ // Triples first so `***foo***` (bold+italic) atomizes as one token
253
+ // instead of being split into `**` pair plus a leftover `*`.
254
+ { delim: "***", regex: /(?<!\w)\*\*\*(\S(?:[\s\S]*?\S)?)\*\*\*(?!\w)/g },
255
+ {
256
+ delim: "___",
257
+ regex: /(?<![A-Za-z0-9])___(\S(?:[\s\S]*?\S)?)___(?![A-Za-z0-9])/g,
258
+ },
259
+ { delim: "**", regex: /(?<!\w)\*\*(\S(?:[\s\S]*?\S)?)\*\*(?!\w)/g },
260
+ // `__` with word-boundary guard to protect intraword underscores
261
+ // (e.g., `my__var__name`). Same for `_` below.
262
+ {
263
+ delim: "__",
264
+ regex: /(?<![A-Za-z0-9])__(\S(?:[\s\S]*?\S)?)__(?![A-Za-z0-9])/g,
265
+ },
266
+ { delim: "~~", regex: /(?<!\w)~~(\S(?:[\s\S]*?\S)?)~~(?!\w)/g },
267
+ { delim: "*", regex: /(?<!\w)\*(\S(?:[^*]*?\S)?)\*(?!\w)/g },
268
+ {
269
+ delim: "_",
270
+ regex: /(?<![A-Za-z0-9])_(\S(?:[^_]*?\S)?)_(?![A-Za-z0-9])/g,
271
+ },
272
+ ];
273
+
274
+ function pairSentinelFor(id: number): string {
275
+ return `${PAIR_SENTINEL_PREFIX}${id}ZZ`;
276
+ }
277
+
278
+ function substituteEmphasisPairs(
279
+ text: string,
280
+ pairMap: Map<string, string>,
281
+ pairToId: Map<string, number>
282
+ ): string {
283
+ for (const { regex } of PAIR_PATTERNS) {
284
+ text = text.replace(regex, (match) => {
285
+ let id = pairToId.get(match);
286
+ if (id === undefined) {
287
+ id = pairToId.size;
288
+ pairMap.set(pairSentinelFor(id), match);
289
+ pairToId.set(match, id);
290
+ }
291
+ return pairSentinelFor(id);
292
+ });
293
+ }
294
+ return text;
295
+ }
296
+
297
+ function restoreEmphasisPairs(
298
+ value: string,
299
+ pairMap: Map<string, string>
300
+ ): string {
301
+ if (pairMap.size === 0) return value;
302
+ return value.replace(PAIR_SENTINEL_PATTERN, (m) => pairMap.get(m) ?? m);
303
+ }
304
+
305
+ // Hyphen sentinel. Hyphens between word chars (e.g. `ninety-five`,
306
+ // `64-byte`, `state-of-the-art`) are semantic compound words, not two
307
+ // tokens. `diffWordsWithSpace` splits on word boundaries, so without
308
+ // this pass `ninety-five` → `ninety-nine` fragments into an unchanged
309
+ // `ninety-` prefix and a swapped `five`/`nine` suffix, producing
310
+ // visually noisy partial-word diffs that confuse readers.
311
+ //
312
+ // Replace each infix hyphen with a word-char marker before diffing and
313
+ // restore afterwards. The sentinel is a fixed string (no per-instance
314
+ // id) because all hyphens restore to the same character — uniqueness
315
+ // isn't needed, only opacity to the word-boundary tokenizer.
316
+ //
317
+ // Runs AFTER the code/link/emphasis sentinel passes so hyphens inside
318
+ // those constructs (e.g. `**state-of-the-art**`, `[link-text](url)`)
319
+ // are already hidden and not re-processed here.
320
+ const HYPHEN_SENTINEL = "PLDIFFHYZZ";
321
+ const HYPHEN_SENTINEL_PATTERN = /PLDIFFHYZZ/g;
322
+ const HYPHEN_INFIX_PATTERN = /(?<=\w)-(?=\w)/g;
323
+
324
+ function substituteHyphens(text: string): string {
325
+ return text.replace(HYPHEN_INFIX_PATTERN, HYPHEN_SENTINEL);
326
+ }
327
+
328
+ function restoreHyphens(value: string): string {
329
+ return value.replace(HYPHEN_SENTINEL_PATTERN, "-");
330
+ }
331
+
332
+ // Coalescing pass (Commit 2). After `diffWordsWithSpace` and sentinel
333
+ // restoration, adjacent word-level changes separated by only "thin"
334
+ // unchanged tokens (whitespace + closed punctuation) get merged into a
335
+ // single phrase-level swap. This turns alternating red/green word-noise
336
+ // into a readable before/after, and rescues the A3 regression where
337
+ // mashed sentinel-plus-word tokens would otherwise render as fragmented
338
+ // literal delimiters inside colored tags.
339
+ //
340
+ // Definitions (from the design doc):
341
+ // - change site: contiguous non-unchanged run
342
+ // - thin unchanged: value consists only of whitespace + { , . ; : — – " ' }
343
+ // (parens/brackets EXCLUDED so inline links stay as hard boundaries)
344
+ // - dirty run: maximal sequence of change sites joined by thin-unchanged
345
+ // Coalesce when a dirty run has ≥2 change sites (asymmetric counts allowed).
346
+ // Single-site dirty runs pass through so isolated swaps keep word-level
347
+ // highlighting.
348
+ const THIN_UNCHANGED_RE =
349
+ /^[\s,.;:—–"'‘’“”]+$/;
350
+
351
+ function isChangeToken(t: InlineDiffToken): boolean {
352
+ return t.type !== "unchanged";
353
+ }
354
+
355
+ function isThinUnchangedToken(t: InlineDiffToken): boolean {
356
+ return t.type === "unchanged" && THIN_UNCHANGED_RE.test(t.value);
357
+ }
358
+
359
+ function coalesceChangeTokens(tokens: InlineDiffToken[]): InlineDiffToken[] {
360
+ const out: InlineDiffToken[] = [];
361
+ let i = 0;
362
+ while (i < tokens.length) {
363
+ if (!isChangeToken(tokens[i])) {
364
+ out.push(tokens[i]);
365
+ i++;
366
+ continue;
367
+ }
368
+ // Scan first change site.
369
+ let lastChangeEnd = i;
370
+ while (
371
+ lastChangeEnd + 1 < tokens.length &&
372
+ isChangeToken(tokens[lastChangeEnd + 1])
373
+ ) {
374
+ lastChangeEnd++;
375
+ }
376
+ let siteCount = 1;
377
+ while (true) {
378
+ let scan = lastChangeEnd + 1;
379
+ while (scan < tokens.length && isThinUnchangedToken(tokens[scan])) scan++;
380
+ if (scan < tokens.length && isChangeToken(tokens[scan])) {
381
+ let newEnd = scan;
382
+ while (
383
+ newEnd + 1 < tokens.length &&
384
+ isChangeToken(tokens[newEnd + 1])
385
+ ) {
386
+ newEnd++;
387
+ }
388
+ lastChangeEnd = newEnd;
389
+ siteCount++;
390
+ } else {
391
+ break;
392
+ }
393
+ }
394
+ if (siteCount >= 2) {
395
+ let removedText = "";
396
+ let addedText = "";
397
+ for (let j = i; j <= lastChangeEnd; j++) {
398
+ const t = tokens[j];
399
+ if (t.type === "removed") removedText += t.value;
400
+ else if (t.type === "added") addedText += t.value;
401
+ else {
402
+ removedText += t.value;
403
+ addedText += t.value;
404
+ }
405
+ }
406
+ if (removedText.length > 0)
407
+ out.push({ type: "removed", value: removedText });
408
+ if (addedText.length > 0) out.push({ type: "added", value: addedText });
409
+ } else {
410
+ for (let j = i; j <= lastChangeEnd; j++) out.push(tokens[j]);
411
+ }
412
+ i = lastChangeEnd + 1;
413
+ }
414
+ return out;
415
+ }
416
+
417
+ function wrapFromBlock(block: Block): InlineDiffWrap {
418
+ if (block.type === "heading") {
419
+ return { type: "heading", level: block.level };
420
+ }
421
+ if (block.type === "list-item") {
422
+ return {
423
+ type: "list-item",
424
+ ordered: block.ordered,
425
+ listLevel: block.level,
426
+ checked: block.checked,
427
+ orderedStart: block.orderedStart,
428
+ };
429
+ }
430
+ return { type: "paragraph" };
431
+ }
432
+
433
+ /**
434
+ * Second-pass word diff on the inline content of a modified block.
435
+ * Returns null (falls back to block-level rendering) if the block doesn't
436
+ * pass the qualification gate. Gate is whitelist-based on block type:
437
+ * only single-block prose-like modifications get the inline treatment.
438
+ */
439
+ export function computeInlineDiff(
440
+ oldContent: string,
441
+ newContent: string
442
+ ): { tokens: InlineDiffToken[]; wrap: InlineDiffWrap } | null {
443
+ const oldBlocks = parseMarkdownToBlocks(oldContent);
444
+ const newBlocks = parseMarkdownToBlocks(newContent);
445
+
446
+ if (oldBlocks.length !== 1 || newBlocks.length !== 1) return null;
447
+
448
+ const [a] = oldBlocks;
449
+ const [b] = newBlocks;
450
+
451
+ if (!INLINE_DIFFABLE_TYPES.has(a.type)) return null;
452
+ if (!structuralFieldsMatch(a, b)) return null;
453
+
454
+ // Atomic passes before word-diffing (longest-lived sentinels first):
455
+ // 1. Inline code spans — protect backtick-wrapped content so diff markers
456
+ // never land between backticks (see SENTINEL_PREFIX comment).
457
+ // 2. Markdown links [text](url) — protect the whole link so diff markers
458
+ // never land inside the link's bracketed text or parenthesized href.
459
+ // 3. Balanced emphasis pairs (**…**, __…__, ~~…~~, *…*, _…_) — replace
460
+ // each whole pair with a unique word-char sentinel so the phrase
461
+ // diffs atomically, avoiding the fragmented/unbalanced-delimiter
462
+ // output that whitespace-splitting `diffWordsWithSpace` produces
463
+ // for multi-word emphasis changes.
464
+ // 4. Infix hyphens — replace `-` between word chars with a word-char
465
+ // marker so hyphenated compounds (`ninety-five`, `64-byte`,
466
+ // `state-of-the-art`) diff as single atomic tokens instead of
467
+ // fragmenting into an unchanged prefix + swapped suffix.
468
+ //
469
+ // Code spans are substituted first so that a backticked literal like
470
+ // `[fake](link)` is treated as code and not accidentally captured by the
471
+ // link regex; similarly, code and link sentinels are opaque to the
472
+ // emphasis-pair regex. Hyphens run last so that hyphens inside those
473
+ // constructs are already hidden. Restorations run in reverse order.
474
+ const codeMap = new Map<string, string>();
475
+ const codeToId = new Map<string, number>();
476
+ const linkMap = new Map<string, string>();
477
+ const linkToId = new Map<string, number>();
478
+ const pairMap = new Map<string, string>();
479
+ const pairToId = new Map<string, number>();
480
+
481
+ let substA = substituteCodeSpans(a.content, codeMap, codeToId);
482
+ let substB = substituteCodeSpans(b.content, codeMap, codeToId);
483
+ substA = substituteLinks(substA, linkMap, linkToId);
484
+ substB = substituteLinks(substB, linkMap, linkToId);
485
+ substA = substituteEmphasisPairs(substA, pairMap, pairToId);
486
+ substB = substituteEmphasisPairs(substB, pairMap, pairToId);
487
+ substA = substituteHyphens(substA);
488
+ substB = substituteHyphens(substB);
489
+
490
+ const changes = diffWordsWithSpace(substA, substB);
491
+ const rawTokens: InlineDiffToken[] = changes.map((c) => ({
492
+ type: c.added ? "added" : c.removed ? "removed" : "unchanged",
493
+ value: restoreCodeSpans(
494
+ restoreLinks(
495
+ restoreEmphasisPairs(restoreHyphens(c.value), pairMap),
496
+ linkMap
497
+ ),
498
+ codeMap
499
+ ),
500
+ }));
501
+
502
+ // Coalesce adjacent change sites separated by thin unchanged context into
503
+ // single phrase-level swaps. Turns alternating red/green word-noise into
504
+ // a readable before/after, and rescues the Case-2 regression where
505
+ // wrapping a previously-plain phrase in emphasis would otherwise surface
506
+ // as fragmented literal delimiters inside colored tags.
507
+ const tokens = coalesceChangeTokens(rawTokens);
508
+
509
+ // Build the render wrapper from the NEW block so ordered-list items that
510
+ // renumbered (e.g., 3. → 4. because a step was inserted above) display the
511
+ // current plan's numeral rather than the previous version's.
512
+ return { tokens, wrap: wrapFromBlock(b) };
513
+ }
514
+
515
+ /**
516
+ * Compute the diff between two plan versions.
517
+ *
518
+ * Groups consecutive remove+add changes into "modified" blocks for
519
+ * better rendering (showing what was replaced rather than separate
520
+ * remove and add blocks). For each modified block, attempts a word-level
521
+ * sub-diff; blocks that pass the qualification gate get `inlineTokens`
522
+ * populated for inline rendering.
523
+ */
524
+ export function computePlanDiff(
525
+ oldText: string,
526
+ newText: string
527
+ ): { blocks: PlanDiffBlock[]; stats: PlanDiffStats } {
528
+ // Pre-pass: collapse every fenced code block to a single-line sentinel
529
+ // so diffLines treats each whole fence atomically. See the comment on
530
+ // FENCE_BLOCK_PATTERN for the failure this prevents.
531
+ const fenceMap = new Map<string, string>();
532
+ const fenceToId = new Map<string, number>();
533
+ const substOld = substituteFencedBlocks(oldText, fenceMap, fenceToId);
534
+ const substNew = substituteFencedBlocks(newText, fenceMap, fenceToId);
535
+
536
+ const rawChanges: Change[] = diffLines(substOld, substNew);
537
+ // Restore the fenced-block content on each change value before the
538
+ // block-building loop consumes it, so downstream rendering and the
539
+ // inline-diff pass see the original fence text.
540
+ const changes: Change[] = rawChanges.map((c) => ({
541
+ ...c,
542
+ value: restoreFencedBlocks(c.value, fenceMap),
543
+ }));
544
+
545
+ const blocks: PlanDiffBlock[] = [];
546
+ const stats: PlanDiffStats = { additions: 0, deletions: 0, modifications: 0 };
547
+
548
+ for (let i = 0; i < changes.length; i++) {
549
+ const change = changes[i];
550
+ const next = changes[i + 1];
551
+
552
+ if (change.removed && next?.added) {
553
+ // Adjacent remove + add = modification
554
+ const inline = computeInlineDiff(change.value, next.value);
555
+ blocks.push({
556
+ type: "modified",
557
+ content: next.value,
558
+ oldContent: change.value,
559
+ lines: countLines(next.value),
560
+ ...(inline ? { inlineTokens: inline.tokens, inlineWrap: inline.wrap } : {}),
561
+ });
562
+ stats.modifications++;
563
+ stats.additions += countLines(next.value);
564
+ stats.deletions += countLines(change.value);
565
+ i++; // skip the next (add) since we consumed it
566
+ } else if (change.added) {
567
+ blocks.push({
568
+ type: "added",
569
+ content: change.value,
570
+ lines: countLines(change.value),
571
+ });
572
+ stats.additions += countLines(change.value);
573
+ } else if (change.removed) {
574
+ blocks.push({
575
+ type: "removed",
576
+ content: change.value,
577
+ lines: countLines(change.value),
578
+ });
579
+ stats.deletions += countLines(change.value);
580
+ } else {
581
+ blocks.push({
582
+ type: "unchanged",
583
+ content: change.value,
584
+ lines: countLines(change.value),
585
+ });
586
+ }
587
+ }
588
+
589
+ return { blocks, stats };
590
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Plan Save Settings Utility
3
+ *
4
+ * Manages settings for automatic plan saving after approval/denial.
5
+ * Users can configure custom save path or disable saving entirely.
6
+ *
7
+ * Uses cookies (not localStorage) because each hook invocation runs on a
8
+ * random port, and localStorage is scoped by origin including port.
9
+ */
10
+
11
+ import { storage } from './storage';
12
+
13
+ const STORAGE_KEY_ENABLED = 'plannotator-save-enabled';
14
+ const STORAGE_KEY_PATH = 'plannotator-save-path';
15
+
16
+ export interface PlanSaveSettings {
17
+ enabled: boolean;
18
+ customPath: string | null;
19
+ }
20
+
21
+ const DEFAULT_SETTINGS: PlanSaveSettings = {
22
+ enabled: true,
23
+ customPath: null, // null means use default ~/.plannotator/plans/
24
+ };
25
+
26
+ /**
27
+ * Get current plan save settings from storage
28
+ */
29
+ export function getPlanSaveSettings(): PlanSaveSettings {
30
+ const enabled = storage.getItem(STORAGE_KEY_ENABLED);
31
+ const customPath = storage.getItem(STORAGE_KEY_PATH);
32
+
33
+ return {
34
+ enabled: enabled !== 'false', // default to true
35
+ customPath: customPath || null,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Save plan save settings to storage
41
+ */
42
+ export function savePlanSaveSettings(settings: PlanSaveSettings): void {
43
+ storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled));
44
+ if (settings.customPath) {
45
+ storage.setItem(STORAGE_KEY_PATH, settings.customPath);
46
+ } else {
47
+ storage.removeItem(STORAGE_KEY_PATH);
48
+ }
49
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Platform detection for keyboard shortcut hints.
3
+ *
4
+ * Canonical source — import from here instead of inlining navigator checks.
5
+ * Used across the plan editor, code review, and shared UI components.
6
+ */
7
+ export const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
8
+ export const modKey = isMac ? '⌘' : 'Ctrl';
9
+ export const altKey = isMac ? '⌥' : 'Alt';
10
+ export const submitHint = isMac ? '⌘↵' : 'Ctrl+Enter';
11
+ export const isWindows = typeof navigator !== 'undefined' && /^Win/.test(navigator.platform);