@plannotator/ui 0.27.0 → 0.29.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 (106) hide show
  1. package/README.md +22 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MarkdownDiff.tsx +60 -0
  19. package/components/MenuVersionSection.tsx +4 -4
  20. package/components/MermaidBlock.tsx +1 -1
  21. package/components/ModeToggle.tsx +7 -6
  22. package/components/OpenInAppButton.tsx +2 -5
  23. package/components/PinpointOverlay.tsx +9 -7
  24. package/components/PlanHeaderMenu.tsx +8 -8
  25. package/components/PopoutDialog.tsx +6 -1
  26. package/components/ResizeHandle.tsx +1 -0
  27. package/components/Settings.tsx +172 -12
  28. package/components/SkillReferenceMenu.tsx +260 -0
  29. package/components/StickyHeaderLane.tsx +7 -0
  30. package/components/ThemeProvider.tsx +131 -32
  31. package/components/ThemeTab.tsx +123 -77
  32. package/components/ToolbarButtons.tsx +29 -8
  33. package/components/Viewer.tsx +396 -130
  34. package/components/VimKeyHud.tsx +695 -0
  35. package/components/VimModeAnnouncementDialog.tsx +557 -0
  36. package/components/VimModeOverlay.tsx +235 -0
  37. package/components/VimTargetReticle.tsx +284 -0
  38. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  39. package/components/blocks/CodeBlock.tsx +18 -18
  40. package/components/blocks/TablePopout.tsx +7 -8
  41. package/components/blocks/TableToolbar.tsx +7 -8
  42. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  43. package/components/html-viewer/HtmlViewer.tsx +450 -47
  44. package/components/html-viewer/annotationNumbering.ts +37 -0
  45. package/components/html-viewer/bridge-script.ts +4051 -298
  46. package/components/html-viewer/composerYield.ts +51 -0
  47. package/components/html-viewer/srcdoc.ts +18 -3
  48. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  49. package/components/icons/themeIcons.tsx +1 -1
  50. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  51. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  52. package/components/settings/HooksTab.tsx +12 -8
  53. package/components/sidebar/FileBrowser.tsx +4 -1
  54. package/components/themeModes.tsx +28 -0
  55. package/config/configStore.ts +76 -1
  56. package/config/settings.ts +152 -0
  57. package/configure.ts +9 -0
  58. package/globals.d.ts +7 -1
  59. package/hooks/useAIChat.ts +5 -2
  60. package/hooks/useAIProviderActivation.ts +47 -0
  61. package/hooks/useAIProviderConfig.ts +5 -1
  62. package/hooks/useAgentSettings.ts +64 -23
  63. package/hooks/useAgents.ts +4 -4
  64. package/hooks/useAnnotationHighlighter.ts +100 -3
  65. package/hooks/useArchive.ts +2 -1
  66. package/hooks/useFenceTheme.ts +17 -0
  67. package/hooks/useLinkedDoc.ts +68 -1
  68. package/hooks/usePinpoint.ts +76 -75
  69. package/hooks/usePlanDiff.ts +73 -2
  70. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  71. package/hooks/useUpdateCheck.ts +1 -2
  72. package/hooks/useVimDocumentFocus.ts +116 -0
  73. package/hooks/useVimSelection.ts +1063 -0
  74. package/package.json +7 -6
  75. package/print.css +14 -13
  76. package/shortcuts/core.ts +38 -13
  77. package/shortcuts/index.ts +10 -0
  78. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  79. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  80. package/shortcuts/runtime.ts +111 -12
  81. package/styles.css +1 -1
  82. package/theme.css +504 -0
  83. package/themes/colorblind.css +89 -0
  84. package/themes/plannotator.css +2 -2
  85. package/types.ts +93 -10
  86. package/utils/agentSwitch.ts +33 -7
  87. package/utils/blockTargeting.ts +462 -178
  88. package/utils/clipboard.ts +110 -0
  89. package/utils/codeBlockMark.ts +50 -0
  90. package/utils/codeHighlight.ts +293 -0
  91. package/utils/codexModels.ts +79 -0
  92. package/utils/domSelection.ts +84 -0
  93. package/utils/htmlChrome.ts +73 -0
  94. package/utils/inputMethod.ts +79 -6
  95. package/utils/parser.ts +517 -21
  96. package/utils/preferenceTtl.ts +15 -0
  97. package/utils/sharing.ts +0 -1
  98. package/utils/skillCatalog.ts +269 -0
  99. package/utils/skillReferences.ts +475 -0
  100. package/utils/syntaxTheme.ts +83 -0
  101. package/utils/themeRegistry.ts +154 -0
  102. package/utils/vimHud.ts +263 -0
  103. package/utils/vimModeAnnouncement.ts +23 -0
  104. package/utils/vimNavigation.ts +417 -0
  105. package/utils/vimReticle.ts +88 -0
  106. package/utils/vimScroll.ts +162 -0
@@ -0,0 +1,110 @@
1
+ function copyTextWithFallback(text: string, focusOwner?: HTMLElement): boolean {
2
+ const activeElement = document.activeElement instanceof HTMLElement
3
+ ? document.activeElement
4
+ : null;
5
+ const selection = window.getSelection();
6
+ const savedRanges = selection
7
+ ? Array.from({ length: selection.rangeCount }, (_, index) =>
8
+ selection.getRangeAt(index).cloneRange())
9
+ : [];
10
+ let copied = false;
11
+
12
+ const handleCopy = (event: ClipboardEvent) => {
13
+ // Without clipboardData there is nothing to write into; leave the native
14
+ // copy untouched (preventDefault would only suppress it) and stay
15
+ // unsuccessful so the textarea path below runs.
16
+ if (!event.clipboardData) return;
17
+ event.preventDefault();
18
+ event.clipboardData.setData('text/plain', text);
19
+ copied = true;
20
+ };
21
+ document.addEventListener('copy', handleCopy);
22
+ try {
23
+ // Success requires BOTH: our handler actually wrote the payload via
24
+ // setData, AND execCommand reported the copy command ran.
25
+ copied = document.execCommand('copy') && copied;
26
+ } catch {
27
+ copied = false;
28
+ } finally {
29
+ document.removeEventListener('copy', handleCopy);
30
+ }
31
+
32
+ if (!copied) {
33
+ const textarea = document.createElement('textarea');
34
+ textarea.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
35
+ // Marker so focus-sensitive surfaces (PopoutDialog's focus-out close
36
+ // guard) can recognize the transient fallback textarea and not treat the
37
+ // focus shift as leaving the dialog.
38
+ textarea.setAttribute('data-clipboard-fallback', 'true');
39
+ textarea.value = text;
40
+ document.body.appendChild(textarea);
41
+ textarea.select();
42
+ try {
43
+ copied = document.execCommand('copy');
44
+ } catch {
45
+ // Clipboard access can be denied by the embedding browser. The caller
46
+ // still regains the same document focus and selection below.
47
+ copied = false;
48
+ }
49
+ textarea.remove();
50
+ }
51
+
52
+ if (activeElement?.isConnected) {
53
+ activeElement.focus({ preventScroll: true });
54
+ } else if (focusOwner?.isConnected) {
55
+ focusOwner.focus({ preventScroll: true });
56
+ }
57
+ if (selection && savedRanges.length > 0) {
58
+ selection.removeAllRanges();
59
+ savedRanges.forEach((range) => selection.addRange(range));
60
+ }
61
+
62
+ return copied;
63
+ }
64
+
65
+ /**
66
+ * Copy text without stealing focus or discarding the host document's native
67
+ * selection. Falls back to the copy event for restricted browser contexts.
68
+ */
69
+ export function copyTextPreservingFocus(
70
+ text: string,
71
+ focusOwner: HTMLElement,
72
+ ): void {
73
+ try {
74
+ const clipboardWrite = navigator.clipboard?.writeText(text);
75
+ if (clipboardWrite) {
76
+ void clipboardWrite.catch(() => {
77
+ copyTextWithFallback(text, focusOwner);
78
+ });
79
+ return;
80
+ }
81
+ } catch {
82
+ // Fall through when a browser exposes Clipboard but rejects access
83
+ // synchronously (for example, in a restricted embedded document).
84
+ }
85
+ copyTextWithFallback(text, focusOwner);
86
+ }
87
+
88
+ /**
89
+ * Copy text to the clipboard, falling back to the legacy copy-event /
90
+ * execCommand path when the async Clipboard API is unavailable (insecure
91
+ * contexts such as remote-mode plain HTTP) or rejects. Resolves `true` when
92
+ * a copy strategy succeeded and `false` otherwise. Never throws.
93
+ */
94
+ export async function copyTextToClipboard(text: string): Promise<boolean> {
95
+ try {
96
+ const clipboardWrite = navigator.clipboard?.writeText(text);
97
+ if (clipboardWrite) {
98
+ await clipboardWrite;
99
+ return true;
100
+ }
101
+ } catch {
102
+ // Clipboard API absent, threw synchronously, or rejected — fall back to
103
+ // the legacy copy-event path below.
104
+ }
105
+ try {
106
+ return copyTextWithFallback(text);
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The annotation `<mark>` that covers a whole fenced code block.
3
+ *
4
+ * `web-highlighter` cannot select inside a `<pre>`, so fenced code is annotated
5
+ * as an all-or-nothing block: one `<mark data-bind-id>` that is the `<code>`
6
+ * element's only child and holds everything the fence renders. Several places
7
+ * need to (re)paint exactly that shape — creating an annotation, and restoring
8
+ * one after `applyHighlight` replaced the element's children — so the DOM
9
+ * contract lives here rather than being written out twice.
10
+ *
11
+ * The children are MOVED into the mark, never flattened to text. Highlighted
12
+ * fences render as Shiki token `<span>`s, and flattening would drop the
13
+ * palette's colours on the floor the moment a block was annotated or
14
+ * re-themed.
15
+ */
16
+ import { AnnotationType } from '../types';
17
+
18
+ export function codeBlockMarkClassName(type: AnnotationType): string {
19
+ return `annotation-highlight ${
20
+ type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''
21
+ }`.trim();
22
+ }
23
+
24
+ /**
25
+ * Wrap everything inside `codeEl` in a single annotation mark and return it.
26
+ *
27
+ * Any mark a previous annotation left behind is unwrapped first, so a second
28
+ * annotation on the same block replaces the first (what has always happened)
29
+ * instead of nesting inside it.
30
+ */
31
+ export function paintCodeBlockMark(
32
+ codeEl: Element,
33
+ id: string,
34
+ type: AnnotationType,
35
+ ): HTMLElement {
36
+ codeEl.querySelectorAll('mark[data-bind-id]').forEach((existing) => {
37
+ const parent = existing.parentNode;
38
+ if (!parent) return;
39
+ while (existing.firstChild) parent.insertBefore(existing.firstChild, existing);
40
+ existing.remove();
41
+ });
42
+
43
+ const doc = codeEl.ownerDocument ?? document;
44
+ const wrapper = doc.createElement('mark');
45
+ wrapper.className = codeBlockMarkClassName(type);
46
+ wrapper.dataset.bindId = id;
47
+ while (codeEl.firstChild) wrapper.appendChild(codeEl.firstChild);
48
+ codeEl.replaceChildren(wrapper);
49
+ return wrapper;
50
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Syntax highlighting for markdown fences and suggestion snippets.
3
+ *
4
+ * There is exactly ONE highlighter in the app: the Shiki instance
5
+ * `@pierre/diffs` already runs for the code-review diff pane
6
+ * (`getSharedHighlighter`, driven by Shiki's JavaScript regex engine). Reusing
7
+ * it rather than standing up a second one buys three things:
8
+ *
9
+ * - Fences render in the SAME resolved theme as the diff pane, so a code
10
+ * block and a diff hunk finally agree about what "Kanagawa Wave" looks
11
+ * like. See `./syntaxTheme.ts`.
12
+ * - Zero added bundle weight. Pierre imports Shiki's full bundle, so every
13
+ * grammar and theme is already inlined; a separate fine-grained highlighter
14
+ * would have duplicated a subset of what is already there.
15
+ * - Every language Shiki bundles, not a hand-curated shortlist.
16
+ *
17
+ * The API is deliberately imperative (`applyHighlight(el, ...)`) because that is
18
+ * exactly the shape the removed `hljs.highlightElement(el)` had. The annotation
19
+ * layer reaches into these `<code>` elements to wrap `<mark>`s and to restore
20
+ * plain text afterwards, so keeping the DOM contract identical keeps that code
21
+ * working untouched.
22
+ *
23
+ * Language-less fences are never highlighted and never guessed at — see #1212.
24
+ * There is no auto-detection anywhere in this module.
25
+ */
26
+
27
+ type PierreModule = typeof import('@pierre/diffs');
28
+
29
+ /**
30
+ * Structural class on every fenced-code `<code>` element.
31
+ *
32
+ * `blockTargeting`, the vim navigation layer and the print stylesheet all
33
+ * address code blocks through `pre > code.pn-code`. It used to be `.hljs`,
34
+ * which named a library the app no longer ships; the hook itself is unchanged,
35
+ * only the name is. The `language-*` class alongside it is still how
36
+ * `blockTargeting` reads a block's language back out of the DOM.
37
+ */
38
+ export const CODE_BLOCK_CLASS = 'pn-code';
39
+
40
+ export function codeBlockClassName(language?: string): string {
41
+ return `${CODE_BLOCK_CLASS} font-mono${language ? ` language-${language}` : ''}`;
42
+ }
43
+
44
+ /** Shiki's `FontStyle` bitmask. Inlined so this module needs no shiki types. */
45
+ const FONT_STYLE_ITALIC = 1;
46
+ const FONT_STYLE_BOLD = 2;
47
+ const FONT_STYLE_UNDERLINE = 4;
48
+ const FONT_STYLE_STRIKETHROUGH = 8;
49
+
50
+ let pierre: PierreModule | undefined;
51
+ let pierreLoad: Promise<PierreModule | undefined> | undefined;
52
+
53
+ /** `${lang} ${theme}` pairs attached to the shared highlighter. */
54
+ const ready = new Set<string>();
55
+ /** Pairs the highlighter refused (unknown grammar or theme). Never retried. */
56
+ const rejected = new Set<string>();
57
+ const inflight = new Map<string, Promise<boolean>>();
58
+
59
+ const pairKey = (lang: string, theme: string) => `${lang} ${theme}`;
60
+
61
+ function loadPierre(): Promise<PierreModule | undefined> {
62
+ pierreLoad ??= import('@pierre/diffs').then(
63
+ (mod) => {
64
+ pierre = mod;
65
+ return mod;
66
+ },
67
+ () => undefined,
68
+ );
69
+ return pierreLoad;
70
+ }
71
+
72
+ function escapeHtml(value: string): string {
73
+ return value
74
+ .replace(/&/g, '&amp;')
75
+ .replace(/</g, '&lt;')
76
+ .replace(/>/g, '&gt;');
77
+ }
78
+
79
+ interface ThemedTokenish {
80
+ content: string;
81
+ color?: string;
82
+ bgColor?: string;
83
+ fontStyle?: number;
84
+ htmlStyle?: Record<string, string> | string;
85
+ }
86
+
87
+ function tokenStyle(token: ThemedTokenish): string {
88
+ if (typeof token.htmlStyle === 'string') return token.htmlStyle;
89
+ const parts: string[] = [];
90
+ if (token.htmlStyle) {
91
+ for (const [prop, value] of Object.entries(token.htmlStyle)) parts.push(`${prop}:${value}`);
92
+ }
93
+ if (token.color) parts.push(`color:${token.color}`);
94
+ if (token.bgColor) parts.push(`background-color:${token.bgColor}`);
95
+ const fontStyle = token.fontStyle ?? 0;
96
+ if (fontStyle > 0) {
97
+ if (fontStyle & FONT_STYLE_ITALIC) parts.push('font-style:italic');
98
+ if (fontStyle & FONT_STYLE_BOLD) parts.push('font-weight:bold');
99
+ const decorations: string[] = [];
100
+ if (fontStyle & FONT_STYLE_UNDERLINE) decorations.push('underline');
101
+ if (fontStyle & FONT_STYLE_STRIKETHROUGH) decorations.push('line-through');
102
+ if (decorations.length) parts.push(`text-decoration:${decorations.join(' ')}`);
103
+ }
104
+ return parts.join(';');
105
+ }
106
+
107
+ /**
108
+ * Highlighted markup for `code`, or `null` when it cannot be produced right now
109
+ * (highlighter not loaded yet, grammar/theme not attached yet, or the tokens do
110
+ * not reconstruct the input exactly).
111
+ *
112
+ * Synchronous by design: once a (lang, theme) pair is attached, every later
113
+ * block using it highlights during the same tick, so there is no flicker on
114
+ * cached highlights.
115
+ */
116
+ export function highlightToHtml(code: string, lang: string, theme: string): string | null {
117
+ const mod = pierre;
118
+ if (!mod || !ready.has(pairKey(lang, theme))) return null;
119
+ const highlighter = mod.getHighlighterIfLoaded();
120
+ if (!highlighter) {
121
+ // The shared highlighter was disposed out from under us; everything we
122
+ // believed was attached is gone with it.
123
+ ready.clear();
124
+ return null;
125
+ }
126
+ let lines: ThemedTokenish[][];
127
+ try {
128
+ lines = highlighter.codeToTokens(code, { lang, theme }).tokens as ThemedTokenish[][];
129
+ } catch {
130
+ ready.delete(pairKey(lang, theme));
131
+ return null;
132
+ }
133
+
134
+ // Invariant: the rendered text must be byte-identical to the source. The
135
+ // annotation layer addresses these blocks by text offset, so a tokenizer that
136
+ // normalised line endings (or dropped a trailing newline) would silently
137
+ // misplace every highlight in the block. Bail to plain text instead.
138
+ let html = '';
139
+ let plain = '';
140
+ for (let i = 0; i < lines.length; i++) {
141
+ if (i > 0) {
142
+ html += '\n';
143
+ plain += '\n';
144
+ }
145
+ for (const token of lines[i]!) {
146
+ plain += token.content;
147
+ const style = tokenStyle(token);
148
+ html += style
149
+ ? `<span style="${style}">${escapeHtml(token.content)}</span>`
150
+ : escapeHtml(token.content);
151
+ }
152
+ }
153
+ return plain === code ? html : null;
154
+ }
155
+
156
+ /**
157
+ * Attach `lang` + `theme` to the shared highlighter. Resolves `false` for
158
+ * grammars or themes Shiki does not know, which is a normal outcome for a fence
159
+ * tagged with something made up: that block simply stays plain.
160
+ */
161
+ export function ensureHighlight(lang: string, theme: string): Promise<boolean> {
162
+ const key = pairKey(lang, theme);
163
+ if (ready.has(key)) return Promise.resolve(true);
164
+ if (rejected.has(key)) return Promise.resolve(false);
165
+ const existing = inflight.get(key);
166
+ if (existing) return existing;
167
+
168
+ const load = loadPierre()
169
+ .then(async (mod) => {
170
+ if (!mod) return false;
171
+ await mod.getSharedHighlighter({
172
+ themes: [theme],
173
+ langs: [lang],
174
+ preferredHighlighter: 'shiki-js',
175
+ });
176
+ ready.add(key);
177
+ return true;
178
+ })
179
+ .catch(() => {
180
+ rejected.add(key);
181
+ return false;
182
+ })
183
+ .then((ok) => {
184
+ inflight.delete(key);
185
+ return ok;
186
+ });
187
+
188
+ inflight.set(key, load);
189
+ return load;
190
+ }
191
+
192
+ /** Monotonic stamp per element so a slow async highlight from a previous
193
+ * content/theme never lands on top of a newer one. */
194
+ const renderSeq = new WeakMap<HTMLElement, number>();
195
+ let seqCounter = 0;
196
+
197
+ type HighlightSwapListener = (el: HTMLElement) => void;
198
+ const swapListeners = new Set<HighlightSwapListener>();
199
+
200
+ /**
201
+ * Observe every write `applyHighlight` makes to a `<code>` element.
202
+ *
203
+ * Each write REPLACES the element's children, which destroys anything the
204
+ * annotation layer wrapped inside it — a whole-fence `<mark data-bind-id>` is
205
+ * gone the moment the palette changes or the first async grammar attach lands.
206
+ * Listeners run SYNCHRONOUSLY, immediately after the write, so re-applying a
207
+ * mark from a listener is ordered by construction rather than by a timer: a
208
+ * restore that ran before the swap is re-established in the same task the swap
209
+ * happened in, and a restore that runs after it finds the mark already there.
210
+ *
211
+ * Returns an unsubscribe function.
212
+ */
213
+ export function onCodeHighlightSwap(listener: HighlightSwapListener): () => void {
214
+ swapListeners.add(listener);
215
+ return () => {
216
+ swapListeners.delete(listener);
217
+ };
218
+ }
219
+
220
+ function notifyHighlightSwap(el: HTMLElement): void {
221
+ if (swapListeners.size === 0) return;
222
+ for (const listener of Array.from(swapListeners)) {
223
+ // A misbehaving observer must never take syntax highlighting down with it.
224
+ try {
225
+ listener(el);
226
+ } catch {}
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Drop-in replacement for `hljs.highlightElement(el)`.
232
+ *
233
+ * Writes plain text immediately so the block has its final size and content on
234
+ * the very first paint (no layout shift, no empty flash), then swaps in
235
+ * highlighted markup when the grammar is attached. When the grammar is already
236
+ * attached the highlighted markup is written straight away with no intermediate
237
+ * plain state.
238
+ */
239
+ export function applyHighlight(
240
+ el: HTMLElement,
241
+ code: string,
242
+ lang: string | undefined,
243
+ theme: string,
244
+ ): void {
245
+ const seq = ++seqCounter;
246
+ renderSeq.set(el, seq);
247
+
248
+ // #1212: a fence with no language stays plain. Never guess.
249
+ if (!lang) {
250
+ el.textContent = code;
251
+ notifyHighlightSwap(el);
252
+ return;
253
+ }
254
+
255
+ const immediate = highlightToHtml(code, lang, theme);
256
+ if (immediate !== null) {
257
+ el.innerHTML = immediate;
258
+ notifyHighlightSwap(el);
259
+ return;
260
+ }
261
+
262
+ el.textContent = code;
263
+ notifyHighlightSwap(el);
264
+ void ensureHighlight(lang, theme).then((ok) => {
265
+ if (!ok || renderSeq.get(el) !== seq || !el.isConnected) return;
266
+ const html = highlightToHtml(code, lang, theme);
267
+ if (html === null) return;
268
+ el.innerHTML = html;
269
+ notifyHighlightSwap(el);
270
+ });
271
+ }
272
+
273
+ /** Test seam: forget every cached attachment and module handle. */
274
+ export function __resetCodeHighlightCacheForTests(): void {
275
+ ready.clear();
276
+ rejected.clear();
277
+ inflight.clear();
278
+ pierre = undefined;
279
+ pierreLoad = undefined;
280
+ }
281
+
282
+ /**
283
+ * Test seam: stand in for `@pierre/diffs` so a test can drive real swaps
284
+ * (including WHEN the async one lands) without loading Shiki's full bundle.
285
+ * Pass `undefined` to go back to the real dynamic import.
286
+ */
287
+ export function __setCodeHighlightModuleForTests(mod: PierreModule | undefined): void {
288
+ ready.clear();
289
+ rejected.clear();
290
+ inflight.clear();
291
+ pierre = mod;
292
+ pierreLoad = mod ? Promise.resolve(mod) : undefined;
293
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Codex Model Catalog
3
+ *
4
+ * Single source of truth for the Codex models offered by the launch panels
5
+ * (AgentsTab + GuideEmptyState) and their per-model reasoning efforts.
6
+ * Aligned with the Codex CLI's own model catalog (codex-cli 0.144): each
7
+ * entry carries the efforts that model actually accepts plus the CLI's
8
+ * default effort for it, so the UI never offers (or launches) an effort the
9
+ * model would reject. Lives in utils/ rather than AgentsTab so
10
+ * useAgentSettings can clamp saved efforts without importing a component.
11
+ */
12
+
13
+ export interface CodexModelOption {
14
+ value: string;
15
+ label: string;
16
+ /** Reasoning efforts this model supports, per the Codex CLI catalog. */
17
+ efforts: string[];
18
+ /** The CLI's default effort for this model — the clamp target when a saved
19
+ * effort isn't in `efforts`. */
20
+ defaultEffort: string;
21
+ }
22
+
23
+ // The two effort ladders in the current catalog. No model supports `minimal`
24
+ // anymore (saved picks migrate to `low` — see useAgentSettings).
25
+ const EFFORTS_THROUGH_XHIGH = ['low', 'medium', 'high', 'xhigh'];
26
+ const EFFORTS_THROUGH_MAX = [...EFFORTS_THROUGH_XHIGH, 'max'];
27
+ const EFFORTS_THROUGH_ULTRA = [...EFFORTS_THROUGH_MAX, 'ultra'];
28
+
29
+ export const CODEX_MODELS: CodexModelOption[] = [
30
+ // GPT-5.6 naming scheme: `-sol` is the flagship, `-terra` is the mid
31
+ // price/performance tier, and `-luna` is the efficient high-volume tier.
32
+ { value: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', efforts: EFFORTS_THROUGH_ULTRA, defaultEffort: 'low' },
33
+ { value: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', efforts: EFFORTS_THROUGH_ULTRA, defaultEffort: 'medium' },
34
+ { value: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', efforts: EFFORTS_THROUGH_MAX, defaultEffort: 'medium' },
35
+ { value: 'gpt-5.5', label: 'GPT-5.5', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
36
+ { value: 'gpt-5.4', label: 'GPT-5.4', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
37
+ { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'high' },
38
+ // gpt-5.2 is retained: it was retired from the ChatGPT product (steered to
39
+ // 5.5) but the API still serves it, so API-key Codex users keep it. The
40
+ // rest of the 5.2/5.1 family (gpt-5.2-codex, gpt-5.1-codex-max,
41
+ // gpt-5.1-codex-mini — and gpt-5.3-codex before them) is API-shut-down per
42
+ // OpenAI's deprecations page (2026-07-23), dead for ALL auth modes; saved
43
+ // picks migrate in useAgentSettings. gpt-5.2 predates max/ultra, so it
44
+ // gets the safe historical effort set.
45
+ { value: 'gpt-5.2', label: 'GPT-5.2', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
46
+ { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', efforts: EFFORTS_THROUGH_XHIGH, defaultEffort: 'medium' },
47
+ ];
48
+
49
+ /** Display labels for the reasoning-effort ids across every model. */
50
+ export const CODEX_EFFORT_LABELS: Record<string, string> = {
51
+ low: 'Low',
52
+ medium: 'Medium',
53
+ high: 'High',
54
+ xhigh: 'XHigh',
55
+ max: 'Max',
56
+ ultra: 'Ultra',
57
+ };
58
+
59
+ // Fallback effort set for a model we don't know (a saved pick of a future
60
+ // model id passes through migration untouched, so the picker still needs
61
+ // SOMETHING to offer). low..xhigh is supported by every catalog model.
62
+ const UNKNOWN_MODEL_EFFORTS = EFFORTS_THROUGH_XHIGH;
63
+
64
+ /** The reasoning-effort picker options for one model — only the efforts that
65
+ * model actually supports. */
66
+ export function codexReasoningOptions(model: string): Array<{ value: string; label: string }> {
67
+ const entry = CODEX_MODELS.find((m) => m.value === model);
68
+ const efforts = entry?.efforts ?? UNKNOWN_MODEL_EFFORTS;
69
+ return efforts.map((value) => ({ value, label: CODEX_EFFORT_LABELS[value] ?? value }));
70
+ }
71
+
72
+ /** Clamp a saved reasoning effort to what the model supports: an unsupported
73
+ * effort snaps to the model's catalog default effort. Unknown models pass
74
+ * through unchanged (we can't know their supported set). */
75
+ export function clampCodexReasoning(model: string, reasoning: string): string {
76
+ const entry = CODEX_MODELS.find((m) => m.value === model);
77
+ if (!entry) return reasoning;
78
+ return entry.efforts.includes(reasoning) ? reasoning : entry.defaultEffort;
79
+ }
@@ -0,0 +1,84 @@
1
+ /** Elements whose text must not become part of a document annotation range. */
2
+ const NON_ANNOTATABLE_SELECTOR = [
3
+ 'button',
4
+ 'input',
5
+ 'textarea',
6
+ 'select',
7
+ 'script',
8
+ 'style',
9
+ '[contenteditable]:not([contenteditable="false"])',
10
+ '[data-pinpoint-ignore]',
11
+ '.select-none',
12
+ '.annotation-toolbar',
13
+ '.katex',
14
+ ].join(',');
15
+
16
+ const DOCUMENT_KEYBOARD_CONTROL_SELECTOR = [
17
+ 'button',
18
+ 'input',
19
+ 'textarea',
20
+ 'select',
21
+ 'a[href]',
22
+ 'summary',
23
+ '[contenteditable]:not([contenteditable="false"])',
24
+ '[role="button"]',
25
+ '[role="link"]',
26
+ '[role="textbox"]',
27
+ '[role="dialog"]',
28
+ ].join(',');
29
+
30
+ /**
31
+ * Return the annotatable text nodes under an element in document order.
32
+ * Existing highlight wrappers remain eligible so Vim selections can overlap
33
+ * annotations just like pointer selections.
34
+ */
35
+ export function getAnnotatableTextNodes(element: Element): Text[] {
36
+ const nodes: Text[] = [];
37
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
38
+ acceptNode(node) {
39
+ const parent = node.parentElement;
40
+ if (!parent || parent.closest(NON_ANNOTATABLE_SELECTOR)) {
41
+ return NodeFilter.FILTER_REJECT;
42
+ }
43
+ return node.textContent?.length
44
+ ? NodeFilter.FILTER_ACCEPT
45
+ : NodeFilter.FILTER_REJECT;
46
+ },
47
+ });
48
+
49
+ let node = walker.nextNode();
50
+ while (node) {
51
+ if (node instanceof Text) nodes.push(node);
52
+ node = walker.nextNode();
53
+ }
54
+ return nodes;
55
+ }
56
+
57
+ /**
58
+ * Return whether a keyboard event originated in an editable or interactive
59
+ * control that must retain ownership of ordinary typing keys.
60
+ */
61
+ export function isDocumentKeyboardControl(target: EventTarget | null): boolean {
62
+ if (!(target instanceof Element)) return false;
63
+ return target.matches(DOCUMENT_KEYBOARD_CONTROL_SELECTOR)
64
+ || target.closest(DOCUMENT_KEYBOARD_CONTROL_SELECTOR) !== null
65
+ || (target instanceof HTMLElement && target.isContentEditable);
66
+ }
67
+
68
+ /**
69
+ * Create a text-node-anchored range spanning an element's annotatable text.
70
+ * Web-highlighter cannot paint element-node endpoints, so callers share this
71
+ * conversion instead of synthesizing subtly different ranges.
72
+ */
73
+ export function createTextRange(element: HTMLElement): Range | null {
74
+ const nodes = getAnnotatableTextNodes(element);
75
+ const firstNode = nodes[0];
76
+ const lastNode = nodes[nodes.length - 1];
77
+
78
+ if (!firstNode || !lastNode) return null;
79
+
80
+ const range = document.createRange();
81
+ range.setStart(firstNode, 0);
82
+ range.setEnd(lastNode, lastNode.length);
83
+ return range;
84
+ }
@@ -0,0 +1,73 @@
1
+ import { storage } from './storage';
2
+ import { isStalePreference } from './preferenceTtl';
3
+
4
+ /**
5
+ * Cross-session chrome visibility for raw-HTML annotate sessions.
6
+ *
7
+ * A raw-HTML session should open as close to "just the page" as possible, so
8
+ * the default is minimal paint: tools hidden, sidebar closed, annotations
9
+ * drawer closed. An explicit change the user makes (showing tools, opening the
10
+ * drawer) persists for later HTML sessions, but only while they keep using
11
+ * HTML annotate: state not refreshed within the staleness TTL (explicit
12
+ * changes or annotation activity re-stamp it) expires back to the minimal
13
+ * defaults. Persisted as a cookie (like every other cross-session UI pref;
14
+ * hook servers run on random ports, and cookies are scoped by domain, not
15
+ * port). Markdown sessions are untouched. A legacy record without a timestamp
16
+ * has an unknowable age and is treated as expired, which one-time resets
17
+ * everyone to the minimal defaults.
18
+ */
19
+
20
+ const STORAGE_KEY = 'plannotator-html-chrome';
21
+
22
+ export interface HtmlChromeState {
23
+ /** The header "Hide tools" toggle — true hides all annotation chrome. */
24
+ toolsHidden: boolean;
25
+ /** Whether the left sidebar was open when the user last left. */
26
+ sidebarOpen: boolean;
27
+ /** Whether the right annotations drawer was open when the user last left. */
28
+ panelOpen: boolean;
29
+ }
30
+
31
+ /** Default: minimal paint — everything hidden, both side surfaces closed. */
32
+ export const DEFAULT_HTML_CHROME_STATE: HtmlChromeState = {
33
+ toolsHidden: true,
34
+ sidebarOpen: false,
35
+ panelOpen: false,
36
+ };
37
+
38
+ /** Pure resolution logic (exported for tests): raw cookie value → state. */
39
+ export function resolveHtmlChromeState(
40
+ raw: string | null,
41
+ now: number = Date.now(),
42
+ ): HtmlChromeState {
43
+ if (!raw) return DEFAULT_HTML_CHROME_STATE;
44
+ try {
45
+ const parsed: unknown = JSON.parse(raw);
46
+ if (typeof parsed !== 'object' || parsed === null) {
47
+ return DEFAULT_HTML_CHROME_STATE;
48
+ }
49
+ const record = parsed as Record<string, unknown>;
50
+ if (isStalePreference(record.savedAt, now)) return DEFAULT_HTML_CHROME_STATE;
51
+ return {
52
+ toolsHidden: typeof record.toolsHidden === 'boolean'
53
+ ? record.toolsHidden
54
+ : DEFAULT_HTML_CHROME_STATE.toolsHidden,
55
+ sidebarOpen: typeof record.sidebarOpen === 'boolean'
56
+ ? record.sidebarOpen
57
+ : DEFAULT_HTML_CHROME_STATE.sidebarOpen,
58
+ panelOpen: typeof record.panelOpen === 'boolean'
59
+ ? record.panelOpen
60
+ : DEFAULT_HTML_CHROME_STATE.panelOpen,
61
+ };
62
+ } catch {
63
+ return DEFAULT_HTML_CHROME_STATE;
64
+ }
65
+ }
66
+
67
+ export function getHtmlChromeState(): HtmlChromeState {
68
+ return resolveHtmlChromeState(storage.getItem(STORAGE_KEY));
69
+ }
70
+
71
+ export function saveHtmlChromeState(state: HtmlChromeState): void {
72
+ storage.setItem(STORAGE_KEY, JSON.stringify({ ...state, savedAt: Date.now() }));
73
+ }