@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,475 @@
1
+ /**
2
+ * Skill references in comments — pure logic.
3
+ *
4
+ * A comment may reference agent skills by name with a `/` or `$` trigger
5
+ * (interchangeable). References live in the comment TEXT itself (`$write-better`),
6
+ * never as separate annotation state, so they survive draft restore, panel
7
+ * edits, and share round-trips, and deleting the token deletes the reference.
8
+ *
9
+ * The catalog comes from `/api/skills` (see utils/skillCatalog.ts). This module
10
+ * is browser-safe and dependency-free: trigger detection for the composer,
11
+ * token extraction against a known catalog, and the export rendering hook the
12
+ * feedback exporters call through a module-level registry seam
13
+ * (`setSkillCatalogForExport`) whose default — an empty catalog — reproduces
14
+ * pre-feature behavior byte-for-byte.
15
+ */
16
+
17
+ export type SkillRootId = 'claude' | 'codex' | 'universal';
18
+
19
+ export interface SkillCatalogEntry {
20
+ name: string;
21
+ root: SkillRootId;
22
+ description?: string;
23
+ /** Skill frontmatter carries `disable-model-invocation: true`: only a human can invoke it. */
24
+ humanOnly: boolean;
25
+ /** Absolute path to the skill directory, when the server provides it. */
26
+ dir?: string;
27
+ }
28
+
29
+ export const SKILL_TRIGGER_CHARS = ['/', '$'] as const;
30
+ export type SkillTriggerChar = (typeof SKILL_TRIGGER_CHARS)[number];
31
+
32
+ /** Longest query the composer keeps treating as a skill lookup. */
33
+ export const MAX_SKILL_QUERY_LEN = 48;
34
+
35
+ /** Characters a skill name (and thus an in-progress query) may contain. */
36
+ const TOKEN_CHARS = /^[A-Za-z0-9._-]*$/;
37
+
38
+ export interface SkillTriggerContext {
39
+ /** Index of the trigger character in the text. */
40
+ start: number;
41
+ trigger: SkillTriggerChar;
42
+ /** Text between the trigger character and the caret. */
43
+ query: string;
44
+ }
45
+
46
+ /**
47
+ * The active skill trigger at `caret`, or null.
48
+ *
49
+ * A trigger is a `/` or `$` that starts a word: at position 0 or preceded by
50
+ * whitespace or `(`. This is what keeps ordinary typing inert — `packages/ui`,
51
+ * `and/or`, and `a/b` never trigger because their `/` follows a word character.
52
+ * The query runs from the trigger to the caret and must stay within skill-name
53
+ * characters, so typing a space (or any other breaking character) ends the
54
+ * lookup naturally.
55
+ *
56
+ * A bare `/` or `$` (empty query) IS a trigger: it opens the full catalog,
57
+ * matching how slash/skill menus behave in the host agents. The safety story
58
+ * for a multi-line composer where Enter means newline and Tab means leave the
59
+ * field lives in the MENU, not here: while the menu is open with NO row
60
+ * explicitly activated (arrow keys), every key behaves exactly as if the menu
61
+ * were closed — "This costs $" + Enter is a newline, "cd /" + Tab leaves the
62
+ * field. See useSkillReferenceAutocomplete.
63
+ */
64
+ export function findSkillTrigger(text: string, caret: number): SkillTriggerContext | null {
65
+ if (caret < 1 || caret > text.length) return null;
66
+
67
+ // Walk back over query characters to the nearest candidate trigger.
68
+ let start = caret - 1;
69
+ while (start >= 0 && !SKILL_TRIGGER_CHARS.includes(text[start] as SkillTriggerChar)) {
70
+ if (caret - start > MAX_SKILL_QUERY_LEN) return null;
71
+ start--;
72
+ }
73
+ if (start < 0) return null;
74
+
75
+ const trigger = text[start] as SkillTriggerChar;
76
+ const before = start === 0 ? '' : text[start - 1];
77
+ if (before !== '' && !/[\s(]/.test(before)) return null;
78
+
79
+ const query = text.slice(start + 1, caret);
80
+ if (query.length > MAX_SKILL_QUERY_LEN) return null;
81
+ if (!TOKEN_CHARS.test(query)) return null;
82
+
83
+ return { start, trigger, query };
84
+ }
85
+
86
+ /**
87
+ * Filter the catalog for the picker: case-insensitive, name prefix matches
88
+ * first, then name substring, then description substring. Stable within each
89
+ * tier (catalog order is alphabetical from the server).
90
+ */
91
+ export function filterSkillCatalog(
92
+ catalog: SkillCatalogEntry[],
93
+ query: string,
94
+ limit = 50,
95
+ ): SkillCatalogEntry[] {
96
+ const q = query.toLowerCase();
97
+ if (!q) return catalog.slice(0, limit);
98
+
99
+ const prefix: SkillCatalogEntry[] = [];
100
+ const substring: SkillCatalogEntry[] = [];
101
+ const description: SkillCatalogEntry[] = [];
102
+ for (const entry of catalog) {
103
+ const name = entry.name.toLowerCase();
104
+ if (name.startsWith(q)) prefix.push(entry);
105
+ else if (name.includes(q)) substring.push(entry);
106
+ else if (entry.description?.toLowerCase().includes(q)) description.push(entry);
107
+ }
108
+ return [...prefix, ...substring, ...description].slice(0, limit);
109
+ }
110
+
111
+ /**
112
+ * Well-known single-segment absolute paths (Filesystem Hierarchy Standard
113
+ * roots). `/run`, `/tmp`, `/etc`… read as filesystem paths in prose, so a
114
+ * `/`-triggered token with one of these names is never extracted as a skill
115
+ * reference, even when a skill shares the name. `$name` remains fully
116
+ * available for such skills, and menu insertion switches a `/` trigger to `$`
117
+ * for them so an inserted reference always survives extraction.
118
+ */
119
+ const RESERVED_PATH_SEGMENTS = new Set([
120
+ 'bin', 'boot', 'dev', 'etc', 'home', 'lib', 'lib64', 'media', 'mnt', 'opt',
121
+ 'proc', 'root', 'run', 'sbin', 'srv', 'sys', 'tmp', 'usr', 'var',
122
+ ]);
123
+
124
+ /**
125
+ * Words that, when they immediately precede a `/`-triggered token on the same
126
+ * line, mark it as prose rather than a skill reference (reproduced false
127
+ * positives against real review comments — see #1229 hardening):
128
+ *
129
+ * - HTTP method verbs: "a POST /agents endpoint", "a GET /show route" — the
130
+ * token reads as a route path.
131
+ * - Modal auxiliaries: "this step could /simplify a lot" — a modal is
132
+ * followed by a bare verb, so the token reads as an emphasized verb, not a
133
+ * skill noun.
134
+ *
135
+ * Deliberately narrow: ordinary verbs ("use /write-better here") and
136
+ * line-starting or post-newline tokens are untouched, `$name` is never
137
+ * affected, and menu insertion switches `/` to `$` when the reference would
138
+ * land after one of these words (see insertSkillReference), so an inserted
139
+ * reference always survives extraction.
140
+ */
141
+ const SLASH_PROSE_PRECEDING_WORDS = new Set([
142
+ // HTTP method verbs
143
+ 'get', 'head', 'post', 'put', 'delete', 'options', 'patch',
144
+ // Modal auxiliaries
145
+ 'can', 'could', 'may', 'might', 'must', 'shall', 'should', 'will', 'would',
146
+ ]);
147
+
148
+ /**
149
+ * True when the character run immediately before `triggerIndex` is same-line
150
+ * whitespace preceded by one of SLASH_PROSE_PRECEDING_WORDS. A newline (or
151
+ * any non-space boundary) between the word and the trigger breaks the pairing
152
+ * — a line-starting `/name` always reads as deliberate.
153
+ */
154
+ function slashTokenReadsAsProse(text: string, triggerIndex: number): boolean {
155
+ const before = text.slice(0, triggerIndex);
156
+ const word = /([A-Za-z]+)[ \t]+$/.exec(before)?.[1];
157
+ return word !== undefined && SLASH_PROSE_PRECEDING_WORDS.has(word.toLowerCase());
158
+ }
159
+
160
+ /**
161
+ * Replace the in-progress trigger token with the chosen skill (keeping the
162
+ * trigger character the user typed) plus a trailing space, so the inserted
163
+ * reference is always cleanly word-bounded. The exceptions to "keep the
164
+ * typed trigger" all switch `/` to `$` because extraction would otherwise
165
+ * drop the inserted reference:
166
+ * - a skill whose name is a well-known absolute path segment (`run`, `tmp`…)
167
+ * — extraction reads `/run` as a path;
168
+ * - a trigger immediately preceded by an HTTP verb or modal auxiliary
169
+ * ("could /simplify") — extraction reads that `/name` as prose (see
170
+ * SLASH_PROSE_PRECEDING_WORDS).
171
+ */
172
+ export function insertSkillReference(
173
+ text: string,
174
+ caret: number,
175
+ trigger: SkillTriggerContext,
176
+ skill: SkillCatalogEntry,
177
+ ): { text: string; caret: number } {
178
+ const triggerChar =
179
+ trigger.trigger === '/' &&
180
+ (RESERVED_PATH_SEGMENTS.has(skill.name.toLowerCase()) ||
181
+ slashTokenReadsAsProse(text, trigger.start))
182
+ ? '$'
183
+ : trigger.trigger;
184
+ const inserted = `${triggerChar}${skill.name} `;
185
+ return {
186
+ text: text.slice(0, trigger.start) + inserted + text.slice(caret),
187
+ caret: trigger.start + inserted.length,
188
+ };
189
+ }
190
+
191
+ /** One skill-reference occurrence in a text, with its exact span. */
192
+ export interface SkillReferenceToken {
193
+ /** Index of the trigger character. */
194
+ start: number;
195
+ /** Index just past the last name character (excludes trailing punctuation). */
196
+ end: number;
197
+ entry: SkillCatalogEntry;
198
+ }
199
+
200
+ /**
201
+ * Every skill-reference occurrence in the text, in order, WITH positions and
202
+ * without dedupe — this is what the composer's highlight overlay paints, so a
203
+ * name referenced twice highlights twice.
204
+ *
205
+ * A reference is a word-starting `/name` or `$name` whose name matches a
206
+ * catalog entry — an exact-case match wins, with case-insensitive matching as
207
+ * a fallback so a catalog holding two skills differing only by case (e.g.
208
+ * `Write-Better` and `write-better` from different roots) never swaps
209
+ * identities on an explicit pick. Path and prose forms are excluded:
210
+ * - followed by `/` or `\` — a path continuation (`/docs/foo`, `$HOME/bin`);
211
+ * - preceded by `](` — a markdown link destination (`[x](/write-better)`);
212
+ * - a `/` token naming a well-known absolute path segment (`/run`, `/tmp`) —
213
+ * see RESERVED_PATH_SEGMENTS; `$run` still counts;
214
+ * - a `/` token immediately preceded on the same line by an HTTP verb or a
215
+ * modal auxiliary ("GET /show", "could /simplify") — see
216
+ * SLASH_PROSE_PRECEDING_WORDS; `$show` still counts.
217
+ *
218
+ * There is deliberately NO shell-redirect exclusion (`cat /run > out`): the
219
+ * motivating case is already covered by the reserved-path rule, and excluding
220
+ * on a following `<`/`>` produced false negatives on ordinary prose
221
+ * ("use /animate <- this one", "quality: /write-better > everything else").
222
+ */
223
+ export function findSkillReferenceTokens(
224
+ text: string,
225
+ catalog: SkillCatalogEntry[],
226
+ ): SkillReferenceToken[] {
227
+ if (!text || catalog.length === 0) return [];
228
+ // Exact-case entries win; the lowercase map is only a fallback, so two
229
+ // skills differing only by case never collapse into one identity (an
230
+ // explicit `$Write-Better` must never resolve to `write-better` — wrong
231
+ // skill, wrong humanOnly flag, wrong SKILL.md injected). First catalog
232
+ // entry wins each fallback slot, matching discovery's first-seen precedence.
233
+ const byExactName = new Map<string, SkillCatalogEntry>();
234
+ const byLowerName = new Map<string, SkillCatalogEntry>();
235
+ for (const s of catalog) {
236
+ if (!byExactName.has(s.name)) byExactName.set(s.name, s);
237
+ const lower = s.name.toLowerCase();
238
+ if (!byLowerName.has(lower)) byLowerName.set(lower, s);
239
+ }
240
+ const lookup = (n: string) => byExactName.get(n) ?? byLowerName.get(n.toLowerCase());
241
+
242
+ const tokens: SkillReferenceToken[] = [];
243
+ const re = /(^|[\s(])([$/])([A-Za-z0-9][A-Za-z0-9._-]*)/g;
244
+ let m: RegExpExecArray | null;
245
+ while ((m = re.exec(text)) !== null) {
246
+ const after = text[m.index + m[0].length];
247
+ if (after === '/' || after === '\\') continue;
248
+ // Markdown link destination: `[label](/name)` is a URL, not a reference.
249
+ if (m[1] === '(' && text[m.index - 1] === ']') continue;
250
+ // Sentence-final dots are punctuation, not part of the name ("use $humanizer.").
251
+ const name = m[3].toLowerCase();
252
+ const trimmed = m[3].replace(/\.+$/, '');
253
+ const trimmedName = trimmed.toLowerCase();
254
+ // `/run`-style well-known absolute paths never count (only for `/`).
255
+ if (m[2] === '/' && (RESERVED_PATH_SEGMENTS.has(name) || RESERVED_PATH_SEGMENTS.has(trimmedName)))
256
+ continue;
257
+ // Prose guard (only for `/`): "GET /show" is a route, "could /simplify"
258
+ // is an emphasized verb — never a skill reference.
259
+ if (m[2] === '/' && slashTokenReadsAsProse(text, m.index + m[1].length)) continue;
260
+ const exact = lookup(m[3]);
261
+ const entry = exact ?? lookup(trimmed);
262
+ if (!entry) continue;
263
+ const start = m.index + m[1].length;
264
+ const nameLen = exact ? m[3].length : trimmed.length;
265
+ tokens.push({ start, end: start + 1 + nameLen, entry });
266
+ }
267
+ return tokens;
268
+ }
269
+
270
+ /**
271
+ * Every skill the text references, in first-appearance order, deduped by name.
272
+ * Same matching rules as findSkillReferenceTokens above.
273
+ */
274
+ export function extractSkillReferences(
275
+ text: string,
276
+ catalog: SkillCatalogEntry[],
277
+ ): SkillCatalogEntry[] {
278
+ const found: SkillCatalogEntry[] = [];
279
+ const seen = new Set<string>();
280
+ for (const token of findSkillReferenceTokens(text, catalog)) {
281
+ if (seen.has(token.entry.name)) continue;
282
+ seen.add(token.entry.name);
283
+ found.push(token.entry);
284
+ }
285
+ return found;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Export seam
290
+ // ---------------------------------------------------------------------------
291
+
292
+ // Module-level registry seam (see packages/ui/CLAUDE.md): the exporters in
293
+ // utils/parser.ts are pure and cannot fetch, so the app registers the fetched
294
+ // catalog here once per session. Default (empty) means the exporters emit
295
+ // nothing extra — hosts without the endpoint are byte-for-byte unchanged.
296
+ let exportCatalog: SkillCatalogEntry[] = [];
297
+
298
+ export function setSkillCatalogForExport(catalog: SkillCatalogEntry[]): void {
299
+ exportCatalog = catalog;
300
+ }
301
+
302
+ export function resetSkillCatalogForExport(): void {
303
+ exportCatalog = [];
304
+ }
305
+
306
+ /**
307
+ * A human-only skill's SKILL.md body, fetched from the server for injection
308
+ * into exported feedback (see utils/skillCatalog.ts, primeSkillContentsForExport).
309
+ */
310
+ export interface SkillExportContent {
311
+ /** Verbatim SKILL.md body (frontmatter stripped), possibly truncated. */
312
+ content: string;
313
+ /** True when the server cut the body at its injection bound. */
314
+ truncated: boolean;
315
+ /** Absolute path to the skill directory. */
316
+ dir: string;
317
+ /** Absolute path to SKILL.md. */
318
+ path: string;
319
+ }
320
+
321
+ // Companion registry to the export catalog: bodies of the human-only skills
322
+ // the session has referenced, fetched lazily. Default (empty) means human-only
323
+ // references fall back to naming the skill plus its directory.
324
+ const exportContents = new Map<string, SkillExportContent>();
325
+
326
+ export function registerSkillContentForExport(name: string, content: SkillExportContent): void {
327
+ exportContents.set(name, content);
328
+ }
329
+
330
+ export function resetSkillContentsForExport(): void {
331
+ exportContents.clear();
332
+ }
333
+
334
+ const HUMAN_ONLY_EXPORT_NOTE =
335
+ 'human-invocation-only: you cannot invoke this skill; the reviewer included it as context';
336
+
337
+ /**
338
+ * The structural forms the injection block itself uses: the BEGIN/END markers
339
+ * and the truncation notice. A skill body line matching one of these could
340
+ * close our block early (everything after it then reads to the receiving
341
+ * agent as the reviewer's own words), forge a BEGIN marker for a skill nobody
342
+ * referenced, or forge a truncation notice pointing at an attacker-chosen
343
+ * path. Skill bodies are user-installed — routinely from third-party repos —
344
+ * so lookalike lines are neutralized before injection. Leading whitespace and
345
+ * case variants count, as do lines dressed in markdown decoration (`> `,
346
+ * `**`, backticks, `#`), unicode-dash or `=` rules, and invisible format
347
+ * characters laced through the words: a loose reader would still take every
348
+ * one of them for markers. The match stays anchored to a leading dash/equals
349
+ * run (after optional decoration), so the words "BEGIN SKILL INSTRUCTIONS"
350
+ * appearing mid-sentence in ordinary prose are never touched.
351
+ */
352
+ const MARKER_LOOKALIKE_RE =
353
+ // Dash run: ASCII hyphen, `=`, the unicode hyphen/dash block U+2010–U+2015
354
+ // (incl. en/em dash), and the minus sign U+2212.
355
+ /^[\s>*_#`]*(?:[-=‐-―−]{2,}\s*(?:BEGIN|END)\s*SKILL\s*INSTRUCTIONS\b|\[\s*Instructions\s*truncated\b)/i;
356
+
357
+ /**
358
+ * Invisible format characters (Unicode category Cf: zero-width space/joiners
359
+ * U+200B–U+200D, word joiner U+2060, BOM/zero-width no-break space U+FEFF,
360
+ * soft hyphens, bidi controls, …). JS `\s` matches none of these, so
361
+ * `---​BEGIN​SKILL​INSTRUCTIONS` slipped past the old regex
362
+ * while still reading as a marker to an LLM. They are stripped (removed, not
363
+ * replaced — an attacker can also lace them INSIDE a word) before matching;
364
+ * the ORIGINAL line is what gets neutralized. The inter-word `\s*` in the
365
+ * regex above is what keeps the stripped, jammed-together form matchable.
366
+ */
367
+ const INVISIBLE_FORMAT_CHARS_RE = /\p{Cf}/gu;
368
+
369
+ const NEUTRALIZED_LINE_PREFIX =
370
+ '[plannotator: the following skill-body line matched an injection marker and was neutralized] ';
371
+
372
+ /**
373
+ * Neutralize body lines that match our own structural markers, visibly: each
374
+ * matching line is kept verbatim but prefixed, never silently deleted, so the
375
+ * line no longer parses as a marker while the reader can still see exactly
376
+ * what the body contained. Matching runs against the line with invisible
377
+ * format characters stripped, but the original line is what is prefixed.
378
+ */
379
+ export function neutralizeSkillMarkerLines(body: string): string {
380
+ return body
381
+ .split('\n')
382
+ .map((line) =>
383
+ MARKER_LOOKALIKE_RE.test(line.replace(INVISIBLE_FORMAT_CHARS_RE, ''))
384
+ ? NEUTRALIZED_LINE_PREFIX + line
385
+ : line,
386
+ )
387
+ .join('\n');
388
+ }
389
+
390
+ /** Instruction block for one injected human-only skill. Unmistakably delimited
391
+ * (BEGIN/END markers survive any markdown inside the body, unlike a fence)
392
+ * and labeled so it can never read as the reviewer's own words. Body lines
393
+ * that imitate the markers are neutralized (see neutralizeSkillMarkerLines)
394
+ * so the body cannot close the block early or forge a sibling block. */
395
+ function injectedSkillSection(name: string, body: SkillExportContent): string {
396
+ let out = `--- BEGIN SKILL INSTRUCTIONS: ${name} ---\n`;
397
+ out += `This skill cannot be invoked by a model, so its instructions are included here at the reviewer's request. Follow them when acting on this feedback.\n`;
398
+ out += `Skill directory: ${body.dir}\n`;
399
+ out += `Resolve any relative paths in the instructions below (e.g. references/, scripts/, assets/) against that absolute directory; your working directory is not the skill directory.\n\n`;
400
+ out += `${neutralizeSkillMarkerLines(body.content)}\n`;
401
+ if (body.truncated) {
402
+ out += `\n[Instructions truncated: this is not the full skill. Read the rest at ${body.path}]\n`;
403
+ }
404
+ out += `--- END SKILL INSTRUCTIONS: ${name} ---\n`;
405
+ return out;
406
+ }
407
+
408
+ /**
409
+ * The export block for a comment's skill references, or '' when the comment
410
+ * references none (or no catalog was registered). Appended to the comment in
411
+ * the exported feedback so the acting agent knows which skills to apply.
412
+ *
413
+ * Model-invocable skills export as names — the agent can fetch those itself.
414
+ * Human-only skills cannot be invoked by the agent, so their SKILL.md body is
415
+ * injected verbatim (when the session fetched it): a human referencing a
416
+ * human-only skill IS the human invocation. When no body is available the
417
+ * skill falls back to its name plus its directory, and to the plain context
418
+ * note when not even the directory is known. Never throws, never emits a
419
+ * partial block.
420
+ *
421
+ * `injectedNames` is an optional per-export dedupe: exporters thread one Set
422
+ * through every comment of a single export so a skill's instructions are
423
+ * injected once, and later references point at the earlier injection.
424
+ *
425
+ * `options.external` marks a comment that was NOT written by the reviewer in
426
+ * this UI — it arrived through the unauthenticated external-annotations API
427
+ * (annotations carrying a `source`), which any local process can post to.
428
+ * The whole justification for injection is that a human referencing a
429
+ * human-only skill IS the human invocation, so a tool-submitted comment must
430
+ * never cause it: external comments still LIST their skill references, but a
431
+ * human-only reference falls back to naming the skill plus its directory
432
+ * (the same shape as the content-unavailable path) instead of injecting.
433
+ */
434
+ export function skillReferenceExportBlock(
435
+ text: string | undefined,
436
+ injectedNames?: Set<string>,
437
+ options?: { external?: boolean },
438
+ ): string {
439
+ if (!text) return '';
440
+ const refs = extractSkillReferences(text, exportCatalog);
441
+ if (refs.length === 0) return '';
442
+
443
+ const external = options?.external === true;
444
+ const toInject: Array<{ name: string; body: SkillExportContent }> = [];
445
+ let out = `**Skills referenced** (the reviewer is asking you to invoke these skills when acting on this feedback):\n`;
446
+ for (const ref of refs) {
447
+ if (!ref.humanOnly) {
448
+ out += `- \`${ref.name}\`\n`;
449
+ continue;
450
+ }
451
+ const body = exportContents.get(ref.name);
452
+ if (body && injectedNames?.has(ref.name)) {
453
+ // Already injected earlier in this export (necessarily by a reviewer-
454
+ // written comment) — true regardless of who references it now.
455
+ out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions are included earlier in this feedback)\n`;
456
+ } else if (external) {
457
+ // Tool-submitted comment: name + directory, never verbatim injection.
458
+ out += ref.dir
459
+ ? `- \`${ref.name}\` (cannot be invoked by a model; this comment came from an external tool, so its instructions are not included — SKILL.md is in ${ref.dir})\n`
460
+ : `- \`${ref.name}\` (${HUMAN_ONLY_EXPORT_NOTE})\n`;
461
+ } else if (body) {
462
+ injectedNames?.add(ref.name);
463
+ toInject.push({ name: ref.name, body });
464
+ out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions are included below at the reviewer's request)\n`;
465
+ } else if (ref.dir) {
466
+ out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions could not be included, read SKILL.md in ${ref.dir} and follow it)\n`;
467
+ } else {
468
+ out += `- \`${ref.name}\` (${HUMAN_ONLY_EXPORT_NOTE})\n`;
469
+ }
470
+ }
471
+ for (const { name, body } of toInject) {
472
+ out += `\n${injectedSkillSection(name, body)}`;
473
+ }
474
+ return out;
475
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Maps a Plannotator colour theme onto the Shiki theme that renders code in it.
3
+ *
4
+ * This used to live in `packages/review-editor/hooks/usePierreTheme.ts` and only
5
+ * served the diff pane. It moved here so the plan/annotate editor's markdown
6
+ * fences resolve the SAME theme the diff pane resolves, which is what makes a
7
+ * fenced code block and a diff hunk finally look like they belong to the same
8
+ * app. `usePierreTheme` re-exports both symbols, so the review editor's imports
9
+ * are unchanged.
10
+ *
11
+ * Names on the right are resolved by `@pierre/diffs` — the `pierre-*` ones come
12
+ * from `@pierre/theme`, the rest from `@shikijs/themes`. Both registries are
13
+ * already bundled (Pierre pulls in Shiki's full bundle), so consuming them here
14
+ * costs no additional bytes.
15
+ */
16
+
17
+ /** Plannotator theme id -> Shiki theme name, per mode. `null` = this palette
18
+ * has no counterpart in that mode and falls back to the Pierre default. */
19
+ export const SHIKI_THEME_MAP: Record<string, { dark: string | null; light: string | null }> = {
20
+ 'andromeeda': { dark: 'andromeeda', light: null },
21
+ 'aurora-x': { dark: 'aurora-x', light: null },
22
+ 'ayu-dark': { dark: 'ayu-dark', light: null },
23
+ 'catppuccin': { dark: 'catppuccin-mocha', light: 'catppuccin-latte' },
24
+ 'colorblind': { dark: 'pierre-dark-protanopia-deuteranopia', light: 'pierre-light-protanopia-deuteranopia' },
25
+ 'dark-plus': { dark: 'dark-plus', light: 'light-plus' },
26
+ 'dracula': { dark: 'dracula', light: null },
27
+ 'everforest': { dark: 'everforest-dark', light: 'everforest-light' },
28
+ 'everforest-hard': { dark: 'everforest-dark', light: 'everforest-light' },
29
+ 'everforest-soft': { dark: 'everforest-dark', light: 'everforest-light' },
30
+ 'github': { dark: 'github-dark', light: 'github-light' },
31
+ 'gruvbox': { dark: 'gruvbox-dark-medium', light: 'gruvbox-light-medium' },
32
+ 'houston': { dark: 'houston', light: null },
33
+ 'kanagawa-dragon': { dark: 'kanagawa-dragon', light: null },
34
+ 'kanagawa-lotus': { dark: null, light: 'kanagawa-lotus' },
35
+ 'kanagawa-wave': { dark: 'kanagawa-wave', light: null },
36
+ 'laserwave': { dark: 'laserwave', light: null },
37
+ 'material': { dark: 'material-theme', light: 'material-theme-lighter' },
38
+ 'min': { dark: 'min-dark', light: 'min-light' },
39
+ 'monokai-pro': { dark: 'monokai', light: null },
40
+ 'night-owl': { dark: 'night-owl', light: null },
41
+ 'nord': { dark: 'nord', light: null },
42
+ 'one-dark-pro': { dark: 'one-dark-pro', light: null },
43
+ 'one-light': { dark: null, light: 'one-light' },
44
+ 'plastic': { dark: 'plastic', light: null },
45
+ 'poimandres': { dark: 'poimandres', light: null },
46
+ 'red': { dark: 'red', light: null },
47
+ 'rose-pine': { dark: 'rose-pine', light: 'rose-pine-dawn' },
48
+ 'slack': { dark: 'slack-dark', light: 'slack-ochin' },
49
+ 'snazzy-light': { dark: null, light: 'snazzy-light' },
50
+ 'solarized': { dark: 'solarized-dark', light: 'solarized-light' },
51
+ 'synthwave-84': { dark: 'synthwave-84', light: null },
52
+ 'tokyo-night': { dark: 'tokyo-night', light: null },
53
+ 'vesper': { dark: 'vesper', light: null },
54
+ 'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' },
55
+ 'vitesse-black': { dark: 'vitesse-black', light: null },
56
+ };
57
+
58
+ /** `@pierre/diffs`' own `DEFAULT_THEMES`. Anything the map does not cover (the
59
+ * Plannotator default palette, plus every palette with no counterpart in the
60
+ * active mode) renders in these, which is exactly what the diff pane does when
61
+ * `resolveSyntaxTheme` returns `undefined`. */
62
+ export const DEFAULT_SYNTAX_THEME = { dark: 'pierre-dark', light: 'pierre-light' } as const;
63
+
64
+ /**
65
+ * The theme pair to hand `@pierre/diffs`, or `undefined` to let it use its own
66
+ * defaults. Returning `undefined` (rather than the default pair) is deliberate:
67
+ * it keeps the diff pane's prop identity stable for palettes that never
68
+ * customised it.
69
+ */
70
+ export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'): { dark: string; light: string } | undefined {
71
+ const map = SHIKI_THEME_MAP[colorTheme];
72
+ if (!map || !map[mode]) return undefined;
73
+ return { dark: map.dark || DEFAULT_SYNTAX_THEME.dark, light: map.light || DEFAULT_SYNTAX_THEME.light };
74
+ }
75
+
76
+ /**
77
+ * The single concrete Shiki theme name for the palette currently on screen.
78
+ * Markdown fences render one mode at a time, so unlike the diff pane (which
79
+ * hands Pierre a dark/light pair and lets CSS pick) they want a resolved name.
80
+ */
81
+ export function resolveFenceTheme(colorTheme: string, mode: 'dark' | 'light'): string {
82
+ return resolveSyntaxTheme(colorTheme, mode)?.[mode] ?? DEFAULT_SYNTAX_THEME[mode];
83
+ }