@plannotator/ui 0.28.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.
- package/README.md +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- 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, '&')
|
|
75
|
+
.replace(/</g, '<')
|
|
76
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|