@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.
Files changed (105) hide show
  1. package/README.md +6 -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/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Skill catalog transport: fetches the catalog (default: `GET /api/skills`)
3
+ * and caches it in memory for a short window so the composer never hits the
4
+ * filesystem per keystroke, while staying ephemeral — nothing is persisted to
5
+ * cookies, config, or storage, and every page session re-reads the catalog
6
+ * from disk via the server.
7
+ *
8
+ * The transport is a host seam (see packages/ui/CLAUDE.md): hosts embedding
9
+ * `@plannotator/ui` with their own backend install a replacement via
10
+ * `setSkillCatalogTransport` / `configurePlannotatorUI({ skillCatalogTransport })`.
11
+ * The default reproduces today's behavior byte-for-byte.
12
+ *
13
+ * Never throws and never rejects: any failure (endpoint missing on a host,
14
+ * network error, malformed payload, a throwing host transport) yields an empty
15
+ * catalog, which renders the composer's `/` and `$` as plain typing.
16
+ *
17
+ * This module also lazily fetches the SKILL.md contents of referenced
18
+ * HUMAN-ONLY skills (default: `GET /api/skills/content?name=`) so the export
19
+ * can inject their instructions — see primeSkillContentsForExport below and
20
+ * skillReferenceExportBlock in utils/skillReferences.ts. Same posture: its
21
+ * failures degrade to the name + directory fallback, never an error.
22
+ */
23
+
24
+ import type { SkillCatalogEntry, SkillExportContent, SkillRootId } from './skillReferences';
25
+ import {
26
+ extractSkillReferences,
27
+ registerSkillContentForExport,
28
+ resetSkillContentsForExport,
29
+ setSkillCatalogForExport,
30
+ } from './skillReferences';
31
+
32
+ const CATALOG_TTL_MS = 30_000;
33
+
34
+ /**
35
+ * Host-override seam for the catalog request. Must resolve to the raw entry
36
+ * list; failures may reject or throw — the cache layer degrades them to [].
37
+ */
38
+ export type SkillCatalogTransport = () => Promise<SkillCatalogEntry[]>;
39
+
40
+ function normalizeEntry(raw: unknown): SkillCatalogEntry | null {
41
+ if (!raw || typeof raw !== 'object') return null;
42
+ const { name, root, description, humanOnly, dir } = raw as Record<string, unknown>;
43
+ if (typeof name !== 'string' || !name) return null;
44
+ const rootId: SkillRootId =
45
+ root === 'claude' || root === 'codex' || root === 'universal' ? root : 'universal';
46
+ return {
47
+ name,
48
+ root: rootId,
49
+ ...(typeof description === 'string' && description ? { description } : {}),
50
+ humanOnly: humanOnly === true,
51
+ ...(typeof dir === 'string' && dir ? { dir } : {}),
52
+ };
53
+ }
54
+
55
+ const defaultTransport: SkillCatalogTransport = async () => {
56
+ const res = await fetch('/api/skills');
57
+ if (!res.ok) return [];
58
+ const data = (await res.json()) as { skills?: unknown };
59
+ if (!Array.isArray(data.skills)) return [];
60
+ return data.skills.map(normalizeEntry).filter((s): s is SkillCatalogEntry => s !== null);
61
+ };
62
+
63
+ let transport: SkillCatalogTransport = defaultTransport;
64
+
65
+ export function setSkillCatalogTransport(next: SkillCatalogTransport): void {
66
+ transport = next;
67
+ }
68
+
69
+ export function resetSkillCatalogTransport(): void {
70
+ transport = defaultTransport;
71
+ }
72
+
73
+ async function requestCatalog(): Promise<SkillCatalogEntry[]> {
74
+ try {
75
+ const skills = await transport();
76
+ return Array.isArray(skills)
77
+ ? skills.map(normalizeEntry).filter((s): s is SkillCatalogEntry => s !== null)
78
+ : [];
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ let cached: { at: number; skills: SkillCatalogEntry[] } | null = null;
85
+ let inflight: Promise<SkillCatalogEntry[]> | null = null;
86
+ // Bumped by resetSkillCatalogCache. A request that resolves after a reset (or
87
+ // after being superseded) must neither populate the cache nor register the
88
+ // export catalog — otherwise a late-resolving stale request overwrites a newer
89
+ // value, and a reset leaves an outstanding request that "revives" dead state.
90
+ let generation = 0;
91
+
92
+ /** Fetch the catalog (deduped in-flight, cached for CATALOG_TTL_MS). */
93
+ export function fetchSkillCatalog(): Promise<SkillCatalogEntry[]> {
94
+ if (cached && Date.now() - cached.at < CATALOG_TTL_MS) {
95
+ return Promise.resolve(cached.skills);
96
+ }
97
+ if (inflight) return inflight;
98
+
99
+ const startedIn = generation;
100
+ const promise = requestCatalog().then((skills) => {
101
+ if (startedIn !== generation) {
102
+ // Cache was reset while this request was outstanding: report what we
103
+ // got, but do not let a dead request write shared state.
104
+ return skills;
105
+ }
106
+ if (inflight === promise) inflight = null;
107
+ // An empty result is not cached as authoritative when a previous fetch
108
+ // succeeded — a transient failure must not blank an established catalog.
109
+ if (skills.length > 0 || !cached || cached.skills.length === 0) {
110
+ cached = { at: Date.now(), skills };
111
+ } else {
112
+ cached = { at: Date.now(), skills: cached.skills };
113
+ }
114
+ setSkillCatalogForExport(cached.skills);
115
+ return cached.skills;
116
+ });
117
+ inflight = promise;
118
+ return promise;
119
+ }
120
+
121
+ /** The last fetched catalog, without triggering a request. */
122
+ export function getCachedSkillCatalog(): SkillCatalogEntry[] {
123
+ return cached?.skills ?? [];
124
+ }
125
+
126
+ /**
127
+ * Fire-and-forget warm-up so export enrichment works even when a comment with
128
+ * references arrives without the composer opening this session (draft restore,
129
+ * annotation-panel edits).
130
+ */
131
+ export function primeSkillCatalog(): void {
132
+ void fetchSkillCatalog();
133
+ }
134
+
135
+ /**
136
+ * Test-only: drop the cache and the export registry, and invalidate any
137
+ * outstanding request so it can no longer write shared state when it lands.
138
+ */
139
+ export function resetSkillCatalogCache(): void {
140
+ generation++;
141
+ cached = null;
142
+ inflight = null;
143
+ contentRequests.clear();
144
+ reportedContentNames.clear();
145
+ setSkillCatalogForExport([]);
146
+ resetSkillContentsForExport();
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Human-only skill contents (lazy, per referenced skill)
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Host-override seam for the content request (see skillCatalogTransport
155
+ * above). Must resolve to the raw `{ name, dir, path, content, truncated }`
156
+ * record for one skill, or null/undefined when unavailable; failures may
157
+ * reject or throw — the cache layer degrades them to "no content", which
158
+ * exports as the name + directory fallback.
159
+ */
160
+ export type SkillContentTransport = (name: string) => Promise<unknown>;
161
+
162
+ const defaultContentTransport: SkillContentTransport = async (name) => {
163
+ const res = await fetch(`/api/skills/content?name=${encodeURIComponent(name)}`);
164
+ if (!res.ok) return null;
165
+ const data = (await res.json()) as { skill?: unknown };
166
+ return data.skill ?? null;
167
+ };
168
+
169
+ let contentTransport: SkillContentTransport = defaultContentTransport;
170
+
171
+ export function setSkillContentTransport(next: SkillContentTransport): void {
172
+ contentTransport = next;
173
+ }
174
+
175
+ export function resetSkillContentTransport(): void {
176
+ contentTransport = defaultContentTransport;
177
+ }
178
+
179
+ function normalizeSkillContent(raw: unknown): SkillExportContent | null {
180
+ if (!raw || typeof raw !== 'object') return null;
181
+ const { content, truncated, dir, path } = raw as Record<string, unknown>;
182
+ if (typeof content !== 'string' || !content) return null;
183
+ if (typeof dir !== 'string' || !dir) return null;
184
+ if (typeof path !== 'string' || !path) return null;
185
+ return { content, truncated: truncated === true, dir, path };
186
+ }
187
+
188
+ // One request per skill name per session (a failure is cached as "no content"
189
+ // and not retried; the export's name + directory fallback covers it). Cleared
190
+ // by resetSkillCatalogCache alongside the catalog itself.
191
+ const contentRequests = new Map<string, Promise<boolean>>();
192
+
193
+ // Names whose registered content has already been reported to a caller as
194
+ // "changed". A cached request stays resolved-true forever, so the "changed"
195
+ // signal must be edge-triggered: without this set, every re-prime re-reports
196
+ // the same landing, and callers that bump a re-render generation on `true`
197
+ // (packages/editor/App.tsx) spin into an unbounded render loop. Cleared by
198
+ // resetSkillCatalogCache alongside the requests.
199
+ const reportedContentNames = new Set<string>();
200
+
201
+ /**
202
+ * Fetch and register the SKILL.md contents for every HUMAN-ONLY skill the
203
+ * given comment texts reference, so skillReferenceExportBlock can inject them.
204
+ * Lazy by design: only referenced human-only skills are fetched — model-
205
+ * invocable skills export as names the agent can invoke itself, so shipping
206
+ * every body up front would be pure bloat.
207
+ *
208
+ * Resolves true only when content newly landed in the registry — i.e. at
209
+ * least one referenced skill's content is registered and has not been
210
+ * reported by a previous call (callers use that to re-render memoized
211
+ * exports, so the signal must be edge-triggered, never level-triggered).
212
+ * Never rejects.
213
+ */
214
+ export async function primeSkillContentsForExport(
215
+ texts: Array<string | undefined | null>,
216
+ ): Promise<boolean> {
217
+ try {
218
+ const catalog = await fetchSkillCatalog();
219
+ if (catalog.length === 0) return false;
220
+
221
+ const names = new Set<string>();
222
+ for (const text of texts) {
223
+ if (!text) continue;
224
+ for (const ref of extractSkillReferences(text, catalog)) {
225
+ if (ref.humanOnly) names.add(ref.name);
226
+ }
227
+ }
228
+ if (names.size === 0) return false;
229
+
230
+ const nameList = [...names];
231
+ const results = await Promise.all(
232
+ nameList.map((name) => {
233
+ let request = contentRequests.get(name);
234
+ if (!request) {
235
+ const startedIn = generation;
236
+ request = (async () => {
237
+ try {
238
+ const skill = normalizeSkillContent(await contentTransport(name));
239
+ if (!skill) return false;
240
+ // A reset while this request was outstanding: report what we
241
+ // got, but never let a dead request write shared state.
242
+ if (startedIn !== generation) return false;
243
+ registerSkillContentForExport(name, skill);
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ })();
249
+ contentRequests.set(name, request);
250
+ }
251
+ return request;
252
+ }),
253
+ );
254
+ // Edge-triggered: only content that landed and has never been reported
255
+ // counts as a change. Concurrent callers awaiting the same request race
256
+ // for the report; exactly one wins, which is enough to bump the caller's
257
+ // generation once.
258
+ let changed = false;
259
+ for (let i = 0; i < nameList.length; i++) {
260
+ if (results[i] && !reportedContentNames.has(nameList[i])) {
261
+ reportedContentNames.add(nameList[i]);
262
+ changed = true;
263
+ }
264
+ }
265
+ return changed;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }