@plannotator/ui 0.28.0 → 0.29.1

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
@@ -12,7 +12,7 @@ import React from 'react';
12
12
  * the dropdown segmented controls use w-3.5, the settings tab uses w-3.
13
13
  */
14
14
 
15
- interface IconProps {
15
+ export interface IconProps {
16
16
  className?: string;
17
17
  }
18
18
 
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import React, { useEffect, useRef, useState, useCallback } from "react";
10
- import hljs from "highlight.js";
11
10
  import { parseMarkdownToBlocks, computeListIndices } from "../../utils/parser";
11
+ import { applyHighlight, codeBlockClassName } from "../../utils/codeHighlight";
12
+ import { useFenceTheme } from "../../hooks/useFenceTheme";
12
13
  import { ListItemBody } from "../ListItemBody";
13
14
  import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types";
14
15
  import { AnnotationType } from "../../types";
@@ -331,6 +332,7 @@ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
331
332
  initialText={commentPopover.initialText}
332
333
  onSubmit={handleCommentSubmit}
333
334
  onClose={handleCommentClose}
335
+ skillReferences
334
336
  />
335
337
  )}
336
338
 
@@ -695,22 +697,20 @@ const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null
695
697
 
696
698
  const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => {
697
699
  const codeRef = useRef<HTMLElement>(null);
700
+ const fenceTheme = useFenceTheme();
698
701
 
699
702
  useEffect(() => {
700
703
  if (codeRef.current) {
701
- codeRef.current.removeAttribute("data-highlighted");
702
- codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ""}`;
703
- hljs.highlightElement(codeRef.current);
704
+ codeRef.current.className = codeBlockClassName(block.language);
705
+ // Language-less fences stay plain (#1212) — applyHighlight never guesses.
706
+ applyHighlight(codeRef.current, block.content, block.language, fenceTheme);
704
707
  }
705
- }, [block.content, block.language]);
708
+ }, [block.content, block.language, fenceTheme]);
706
709
 
707
710
  return (
708
711
  <div className="relative group my-5">
709
712
  <pre className="bg-muted/50 border border-border/30 rounded-lg overflow-x-auto">
710
- <code
711
- ref={codeRef}
712
- className={`hljs font-mono${block.language ? ` language-${block.language}` : ""}`}
713
- >
713
+ <code ref={codeRef} className={codeBlockClassName(block.language)}>
714
714
  {block.content}
715
715
  </code>
716
716
  </pre>
@@ -13,6 +13,18 @@ interface PlanDiffBadgeProps {
13
13
  isActive: boolean;
14
14
  onToggle: () => void;
15
15
  hasPreviousVersion: boolean;
16
+ /**
17
+ * Optional baseline context for surfaces whose diff baseline is NOT "the
18
+ * previous plan revision" — annotate/folder sessions pass "since last
19
+ * review" so the counts read against the right baseline (not, e.g., the
20
+ * git uncommitted-vs-HEAD numbers shown elsewhere). Rendered as a short
21
+ * muted suffix after the counts. Plan review passes nothing and renders
22
+ * exactly as before.
23
+ */
24
+ baselineLabel?: string;
25
+ /** Tooltip override for the inactive state, paired with `baselineLabel`
26
+ * (e.g. "Changes since you last reviewed this file"). */
27
+ baselineTooltip?: string;
16
28
  }
17
29
 
18
30
  export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
@@ -20,6 +32,8 @@ export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
20
32
  isActive,
21
33
  onToggle,
22
34
  hasPreviousVersion,
35
+ baselineLabel,
36
+ baselineTooltip,
23
37
  }) => {
24
38
  if (!hasPreviousVersion || !stats) return null;
25
39
 
@@ -34,7 +48,11 @@ export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
34
48
  ? "bg-primary/15"
35
49
  : "bg-muted/50 hover:bg-muted"
36
50
  }`}
37
- title={isActive ? "Exit plan diff view" : "Show what changed from previous version"}
51
+ title={
52
+ isActive
53
+ ? "Exit plan diff view"
54
+ : baselineTooltip ?? "Show what changed from previous version"
55
+ }
38
56
  >
39
57
  <span className={isActive ? "text-success" : "text-success/70"}>
40
58
  +{stats.additions}
@@ -43,6 +61,9 @@ export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
43
61
  <span className={isActive ? "text-destructive" : "text-destructive/70"}>
44
62
  -{stats.deletions}
45
63
  </span>
64
+ {baselineLabel && (
65
+ <span className="text-muted-foreground/60 ml-1">{baselineLabel}</span>
66
+ )}
46
67
  </button>
47
68
  );
48
69
  };
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useState } from 'react';
2
- import { FAVICON_SVG } from '@plannotator/core/favicon';
2
+ import { FAVICON_PNG_DATA_URL } from '@plannotator/core/favicon';
3
+ import { copyTextToClipboard } from '../../utils/clipboard';
3
4
 
4
5
  interface HooksStatus {
5
6
  pfmReminder: { enabled: boolean };
@@ -22,7 +23,8 @@ const CopyPathButton: React.FC<{ filePath: string }> = ({ filePath }) => {
22
23
  const [copied, setCopied] = useState(false);
23
24
 
24
25
  const copy = () => {
25
- navigator.clipboard.writeText(filePath).then(() => {
26
+ void copyTextToClipboard(filePath).then((ok) => {
27
+ if (!ok) return;
26
28
  setCopied(true);
27
29
  setTimeout(() => setCopied(false), 1500);
28
30
  });
@@ -86,9 +88,10 @@ export const HooksTab: React.FC = () => {
86
88
  {/* PFM Reminder Card */}
87
89
  <div className="rounded-lg border border-border bg-card p-4 space-y-3">
88
90
  <div className="flex items-start gap-3">
89
- <div
90
- className="w-8 h-8 flex-shrink-0 rounded-md overflow-hidden"
91
- dangerouslySetInnerHTML={{ __html: FAVICON_SVG }}
91
+ <img
92
+ src={FAVICON_PNG_DATA_URL}
93
+ alt=""
94
+ className="w-8 h-8 flex-shrink-0 object-contain"
92
95
  />
93
96
  <div className="flex-1 min-w-0">
94
97
  <div className="flex items-center justify-between gap-2">
@@ -130,9 +133,10 @@ export const HooksTab: React.FC = () => {
130
133
  {/* Compound Improvement Hook Card */}
131
134
  <div className="rounded-lg border border-border bg-card p-4 space-y-3">
132
135
  <div className="flex items-start gap-3">
133
- <div
134
- className="w-8 h-8 flex-shrink-0 rounded-md overflow-hidden"
135
- dangerouslySetInnerHTML={{ __html: FAVICON_SVG }}
136
+ <img
137
+ src={FAVICON_PNG_DATA_URL}
138
+ alt=""
139
+ className="w-8 h-8 flex-shrink-0 object-contain"
136
140
  />
137
141
  <div className="flex-1 min-w-0">
138
142
  <div className="flex items-center justify-between gap-2">
@@ -43,6 +43,9 @@ interface AggregateWorkspaceChange {
43
43
  files: number;
44
44
  }
45
45
 
46
+ // Display-name stripping only — deliberately narrower than the annotatable
47
+ // set. Config files (config.yaml vs config.json) keep their extensions so
48
+ // same-named siblings stay distinguishable in the tree.
46
49
  const FILE_EXTENSION_RE = /\.(mdx?|txt|html?)$/i;
47
50
 
48
51
  function normalizeFilterText(value: string): string {
@@ -418,7 +421,7 @@ const DirSection: React.FC<{
418
421
  if (dir.tree.length === 0) {
419
422
  return (
420
423
  <div className="px-3 py-2 text-[11px] text-muted-foreground">
421
- No markdown or text files found
424
+ No annotatable files found
422
425
  </div>
423
426
  );
424
427
  }
@@ -0,0 +1,28 @@
1
+ import type { FC } from 'react';
2
+ import { MoonIcon, SunIcon, SystemIcon, type IconProps } from './icons/themeIcons';
3
+
4
+ interface ThemeMode {
5
+ readonly id: string;
6
+ readonly label: string;
7
+ readonly Icon: FC<IconProps>;
8
+ }
9
+
10
+ /** The shared catalog rendered by every Light/Dark/System picker. */
11
+ export const THEME_MODES = [
12
+ { id: 'light', label: 'Light', Icon: SunIcon },
13
+ { id: 'dark', label: 'Dark', Icon: MoonIcon },
14
+ { id: 'system', label: 'System', Icon: SystemIcon },
15
+ ] as const satisfies readonly ThemeMode[];
16
+
17
+ /** A persisted theme-mode preference. */
18
+ export type Mode = (typeof THEME_MODES)[number]['id'];
19
+
20
+ /** Return whether an unknown persisted value is a supported theme mode. */
21
+ export function isThemeMode(value: unknown): value is Mode {
22
+ return typeof value === 'string' && THEME_MODES.some(({ id }) => id === value);
23
+ }
24
+
25
+ /** Parse an unknown persisted value, falling back to a caller-provided mode. */
26
+ export function parseThemeMode(value: unknown, fallback: Mode): Mode {
27
+ return isThemeMode(value) ? value : fallback;
28
+ }
@@ -29,6 +29,28 @@ function deepMerge(target: Record<string, unknown>, source: Record<string, unkno
29
29
  }
30
30
  }
31
31
 
32
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ /**
37
+ * Remove every leaf `shape` describes from `target`, pruning containers that
38
+ * empty out. Used to retract a queued server write once the server has spoken
39
+ * for the same leaves.
40
+ */
41
+ function deletePaths(target: Record<string, unknown>, shape: Record<string, unknown>): void {
42
+ for (const key of Object.keys(shape)) {
43
+ if (!(key in target)) continue;
44
+ if (isPlainObject(target[key]) && isPlainObject(shape[key])) {
45
+ const child = target[key] as Record<string, unknown>;
46
+ deletePaths(child, shape[key] as Record<string, unknown>);
47
+ if (Object.keys(child).length === 0) delete target[key];
48
+ } else {
49
+ delete target[key];
50
+ }
51
+ }
52
+ }
53
+
32
54
  /** Server write-back transport: posts a batch of changed server-synced settings. */
33
55
  export type ServerSyncFn = (payload: Record<string, unknown>) => void;
34
56
 
@@ -56,6 +78,10 @@ class ConfigStore {
56
78
  private serverSyncTimer: ReturnType<typeof setTimeout> | null = null;
57
79
  private pagehideFlushRegistered = false;
58
80
  private serverSync: ServerSyncFn = defaultServerSync;
81
+ /** True once a host installed its own transport via setServerSync(). */
82
+ private serverSyncInstalled = false;
83
+ /** Settings whose value came from the server config; seeds must not undo them. */
84
+ private serverOverridden = new Set<string>();
59
85
  private loaded = false;
60
86
 
61
87
  /**
@@ -132,6 +158,17 @@ class ConfigStore {
132
158
  if (fromServer !== undefined) {
133
159
  this.values.set(name, fromServer);
134
160
  def.toCookie(fromServer as never);
161
+ this.serverOverridden.add(name);
162
+ // Retract any write still queued for the leaves the server just
163
+ // spoke for. Without this, a write queued before init() flushes
164
+ // AFTER it and pushes the pre-init value back to the config file,
165
+ // which the next session would then restore over the cookie.
166
+ if (def.toServer) {
167
+ deletePaths(
168
+ this.pendingServerWrites,
169
+ def.toServer(fromServer as never) as Record<string, unknown>,
170
+ );
171
+ }
135
172
  }
136
173
  }
137
174
  }
@@ -139,6 +176,40 @@ class ConfigStore {
139
176
  this.notify();
140
177
  }
141
178
 
179
+ /**
180
+ * Install a resolved default for a setting: memory + cookie, never the
181
+ * server. For values a component computes at mount (e.g. ThemeProvider's
182
+ * default theme pair) rather than values a user chose — seeding must not
183
+ * write to ~/.plannotator/config.json, and must never overwrite a value the
184
+ * server already supplied through init().
185
+ */
186
+ seed<K extends SettingName>(key: K, value: SettingValue<K>): void {
187
+ this.ensureLoaded();
188
+ if (this.serverOverridden.has(key)) return;
189
+ const def = SETTINGS[key];
190
+ this.values.set(key, value);
191
+ def.toCookie(value as never);
192
+ this.notify();
193
+ }
194
+
195
+ /**
196
+ * Persist a user choice to memory + cookie, queuing the server write-back
197
+ * only when this store is actually talking to a server. Hosts that never
198
+ * installed a serverSync seam have no /api/config endpoint, so the legacy
199
+ * cookie-only APIs (ThemeProvider.setColorTheme) use this instead of set().
200
+ */
201
+ setLocal<K extends SettingName>(key: K, value: SettingValue<K>): void {
202
+ if (this.serverSyncInstalled) {
203
+ this.set(key, value);
204
+ return;
205
+ }
206
+ this.ensureLoaded();
207
+ const def = SETTINGS[key];
208
+ this.values.set(key, value);
209
+ def.toCookie(value as never);
210
+ this.notify();
211
+ }
212
+
142
213
  /** Get a resolved config value. Works outside React. */
143
214
  get<K extends SettingName>(key: K): SettingValue<K> {
144
215
  this.ensureLoaded();
@@ -169,9 +240,13 @@ class ConfigStore {
169
240
  /** Override the server write-back transport (default = inline POST /api/config). */
170
241
  setServerSync(fn: ServerSyncFn): void {
171
242
  this.serverSync = fn;
243
+ this.serverSyncInstalled = true;
172
244
  }
173
245
 
174
- resetServerSync(): void { this.serverSync = defaultServerSync; }
246
+ resetServerSync(): void {
247
+ this.serverSync = defaultServerSync;
248
+ this.serverSyncInstalled = false;
249
+ }
175
250
 
176
251
  private notify(): void {
177
252
  this.version++;
@@ -12,6 +12,69 @@
12
12
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
13
13
  import { storage } from '../utils/storage';
14
14
  import { generateIdentity } from '../utils/generateIdentity';
15
+ import {
16
+ getDefaultThemePair,
17
+ normalizeThemePair,
18
+ seedThemePair,
19
+ type ThemePair,
20
+ } from '../utils/themeRegistry';
21
+ import { parseThemeMode } from '../components/themeModes';
22
+
23
+ /** Legacy single-palette key, still written so a downgrade renders styled. */
24
+ const COLOR_THEME_COOKIE = 'plannotator-color-theme';
25
+ const MODE_COOKIE = 'plannotator-theme';
26
+ const LIGHT_THEME_COOKIE = 'plannotator-light-theme';
27
+ const DARK_THEME_COOKIE = 'plannotator-dark-theme';
28
+
29
+ /**
30
+ * Where a host keeps the two PRE-PAIR values, if not under Plannotator's own
31
+ * keys. These mirror ThemeProvider's `storageKey` / `colorThemeStorageKey`
32
+ * props, which existed before the pair and are what a host's already-stored
33
+ * user preference lives under.
34
+ *
35
+ * Only these two legacy keys are overridable. The pair halves are a new
36
+ * concept with no pre-existing host data, so they always use the fixed
37
+ * `plannotator-light-theme` / `plannotator-dark-theme` keys; two hosts sharing
38
+ * one origin with different key prefixes would share those halves.
39
+ */
40
+ export interface ThemePairLegacyKeys {
41
+ /** Where the mode is stored (ThemeProvider's `storageKey`). */
42
+ mode?: string;
43
+ /** Where the single pre-pair palette is stored (`colorThemeStorageKey`). */
44
+ colorTheme?: string;
45
+ }
46
+
47
+ /**
48
+ * Persist a pair to its cookies without touching the server.
49
+ *
50
+ * ThemeProvider calls this once it has resolved a pair, because a pair
51
+ * migrated from the legacy single-palette key is DERIVED until it is written:
52
+ * the provider then mirrors the active palette back onto that legacy key, so
53
+ * leaving the halves underived would lose the migration on the next load.
54
+ */
55
+ export function writeThemePairCookies(pair: ThemePair, keys?: ThemePairLegacyKeys): void {
56
+ storage.setItem(keys?.mode ?? MODE_COOKIE, pair.mode);
57
+ storage.setItem(LIGHT_THEME_COOKIE, pair.light);
58
+ storage.setItem(DARK_THEME_COOKIE, pair.dark);
59
+ }
60
+
61
+ /**
62
+ * Read the persisted pair, seeding either half from the single palette older
63
+ * releases stored. Returns undefined only when the user has never expressed a
64
+ * theme preference at all — ThemeProvider reads that as "my props decide".
65
+ *
66
+ * `keys` points the two legacy reads at a host's own storage keys so an
67
+ * upgrade migrates that host's stored preference instead of discarding it.
68
+ */
69
+ export function readThemePairCookies(keys?: ThemePairLegacyKeys): ThemePair | undefined {
70
+ const mode = storage.getItem(keys?.mode ?? MODE_COOKIE);
71
+ const light = storage.getItem(LIGHT_THEME_COOKIE);
72
+ const dark = storage.getItem(DARK_THEME_COOKIE);
73
+ const legacy = storage.getItem(keys?.colorTheme ?? COLOR_THEME_COOKIE);
74
+ if (!mode && !light && !dark && !legacy) return undefined;
75
+ const seeded = seedThemePair(legacy, parseThemeMode(mode, getDefaultThemePair().mode));
76
+ return normalizeThemePair({ mode, light: light ?? seeded.light, dark: dark ?? seeded.dark }, seeded);
77
+ }
15
78
 
16
79
  const DIFF_LINE_BG_INTENSITY_VALUES = ['subtle', 'normal', 'strong'] as const;
17
80
  function isDiffLineBgIntensity(v: unknown): v is DiffLineBgIntensity {
@@ -28,6 +91,7 @@ export interface SettingDef<T> {
28
91
  toServer?: (value: T) => Record<string, unknown>;
29
92
  }
30
93
 
94
+ /** Typed registry of persisted UI settings and their storage codecs. */
31
95
  export const SETTINGS = {
32
96
  displayName: {
33
97
  defaultValue: () => generateIdentity(),
@@ -39,6 +103,35 @@ export const SETTINGS = {
39
103
  toServer: (v: string) => ({ displayName: v }),
40
104
  },
41
105
 
106
+ /**
107
+ * Appearance: the mode plus the palette assigned to each half of the pair.
108
+ * Stored as one value because the three fields are only meaningful together —
109
+ * `mode: system` picks between `light` and `dark` at render time.
110
+ *
111
+ * Cookies: `plannotator-theme` (mode) keeps its meaning, joined by
112
+ * `plannotator-light-theme` / `plannotator-dark-theme`. A user arriving from
113
+ * an older release has neither half, so the pair is seeded from the single
114
+ * `plannotator-color-theme` palette they were on (ThemeProvider keeps writing
115
+ * that key, so a downgrade still finds a palette and never renders unstyled).
116
+ *
117
+ * Server: round-trips through `theme` in ~/.plannotator/config.json exactly
118
+ * like `diffOptions` does, so the choice survives the random port each hook
119
+ * invocation runs on.
120
+ */
121
+ themePair: {
122
+ defaultValue: () => getDefaultThemePair(),
123
+ fromCookie: () => readThemePairCookies(),
124
+ toCookie: (v: ThemePair) => writeThemePairCookies(v),
125
+ serverKey: 'theme',
126
+ fromServer: (sc: Record<string, unknown>) => {
127
+ const theme = sc.theme as Record<string, unknown> | undefined;
128
+ if (!theme || typeof theme !== 'object') return undefined;
129
+ if (theme.mode === undefined && theme.light === undefined && theme.dark === undefined) return undefined;
130
+ return normalizeThemePair(theme, getDefaultThemePair());
131
+ },
132
+ toServer: (v: ThemePair) => ({ theme: { mode: v.mode, light: v.light, dark: v.dark } }),
133
+ },
134
+
42
135
  gridEnabled: {
43
136
  // Default ON: plans open in the classic grid / floating-card look. The UI 2.0
44
137
  // flat look is offered as an opt-in via the look-and-feel chooser dialog.
@@ -51,6 +144,46 @@ export const SETTINGS = {
51
144
  serverKey: undefined, fromServer: undefined, toServer: undefined,
52
145
  },
53
146
 
147
+ vimModeEnabled: {
148
+ // Vim bindings deliberately default OFF. Unmodified letter keys must remain
149
+ // inert for existing users until they explicitly opt into modal document
150
+ // navigation from Settings > Vim.
151
+ defaultValue: false as boolean,
152
+ fromCookie: () => {
153
+ const value = storage.getItem('plannotator-vim-mode-enabled');
154
+ return value === 'true' ? true : value === 'false' ? false : undefined;
155
+ },
156
+ toCookie: (value: boolean) =>
157
+ storage.setItem('plannotator-vim-mode-enabled', String(value)),
158
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
159
+ },
160
+
161
+ vimHudEnabled: {
162
+ // The larger command HUD is an optional presentation layer on top of Vim
163
+ // controls. It has no effect while Vim mode itself is disabled.
164
+ defaultValue: false as boolean,
165
+ fromCookie: () => {
166
+ const value = storage.getItem('plannotator-vim-hud-enabled');
167
+ return value === 'true' ? true : value === 'false' ? false : undefined;
168
+ },
169
+ toCookie: (value: boolean) =>
170
+ storage.setItem('plannotator-vim-hud-enabled', String(value)),
171
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
172
+ },
173
+
174
+ vimHudKeyPanelEnabled: {
175
+ // Preserve the existing full HUD for current users while allowing the
176
+ // bottom-right key panel to be hidden independently from the reticle.
177
+ defaultValue: true as boolean,
178
+ fromCookie: () => {
179
+ const value = storage.getItem('plannotator-vim-hud-key-panel-enabled');
180
+ return value === 'true' ? true : value === 'false' ? false : undefined;
181
+ },
182
+ toCookie: (value: boolean) =>
183
+ storage.setItem('plannotator-vim-hud-key-panel-enabled', String(value)),
184
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
185
+ },
186
+
54
187
  // --- Diff display options (namespaced under diffOptions in config.json) ---
55
188
 
56
189
  // Which left-panel view a code review OPENS in. 'sections' = the git-status
@@ -262,6 +395,25 @@ export const SETTINGS = {
262
395
  },
263
396
  toServer: (v: DiffLineBgIntensity) => ({ diffOptions: { lineBgIntensity: v } }),
264
397
  },
398
+ /** Experimental: author suggestions by editing code in place in the review
399
+ * all-files view. Cookie-only (no server sync) while the feature is
400
+ * experimental — default OFF, and when off no edit UI renders and no
401
+ * editor is ever constructed. (In code-split hosts the editor chunk is
402
+ * never fetched; Plannotator's single-file production build inlines all
403
+ * dynamic imports, so there the module namespace exists at page load —
404
+ * audited free of top-level side effects — but stays inert.) */
405
+ editSuggestions: {
406
+ defaultValue: false as boolean,
407
+ fromCookie: () => {
408
+ const v = storage.getItem('plannotator-experimental-edit-suggestions');
409
+ return v === 'true' ? true : v === 'false' ? false : undefined;
410
+ },
411
+ toCookie: (v: boolean) =>
412
+ storage.setItem('plannotator-experimental-edit-suggestions', String(v)),
413
+ serverKey: undefined,
414
+ fromServer: undefined,
415
+ toServer: undefined,
416
+ },
265
417
  conventionalComments: {
266
418
  defaultValue: false as boolean,
267
419
  fromCookie: () => {
package/configure.ts CHANGED
@@ -7,6 +7,7 @@ import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser
7
7
  import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft';
8
8
  import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations';
9
9
  import { setAITransport, type AITransport } from './hooks/useAIChat';
10
+ import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
10
11
  import { configStore } from './config';
11
12
  import type { ServerSyncFn } from './config/configStore';
12
13
  import type { ExternalAnnotationEvent, VaultNode } from './types';
@@ -27,6 +28,8 @@ export type {
27
28
  ExternalAnnotationTransport,
28
29
  ExternalAnnotationEvent,
29
30
  AITransport,
31
+ SkillCatalogTransport,
32
+ SkillContentTransport,
30
33
  ServerSyncFn,
31
34
  };
32
35
 
@@ -48,6 +51,10 @@ export interface PlannotatorUIConfig {
48
51
  */
49
52
  externalAnnotationTransport?: ExternalAnnotationTransport<ExternalAnnotationBase>;
50
53
  aiTransport?: AITransport;
54
+ /** Skill-reference catalog request. Default: `GET /api/skills` on the page origin. */
55
+ skillCatalogTransport?: SkillCatalogTransport;
56
+ /** Human-only skill contents request for feedback injection. Default: `GET /api/skills/content?name=` on the page origin. */
57
+ skillContentTransport?: SkillContentTransport;
51
58
  serverSync?: ServerSyncFn;
52
59
  /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
53
60
  loadSettingsFromBackend?: boolean;
@@ -63,6 +70,8 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
63
70
  if (config.draftTransport) setDraftTransport(config.draftTransport);
64
71
  if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport);
65
72
  if (config.aiTransport) setAITransport(config.aiTransport);
73
+ if (config.skillCatalogTransport) setSkillCatalogTransport(config.skillCatalogTransport);
74
+ if (config.skillContentTransport) setSkillContentTransport(config.skillContentTransport);
66
75
  if (config.serverSync) configStore.setServerSync(config.serverSync);
67
76
  // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
68
77
  if (config.loadSettingsFromBackend) configStore.loadFromBackend();
package/globals.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Allow side-effect CSS imports (highlight.js themes, overlayscrollbars, etc.)
1
+ // Allow side-effect CSS imports (overlayscrollbars, fontsource, etc.)
2
2
  declare module '*.css';
3
3
 
4
4
  // Image asset imports (sprites, screenshots). Consumers compiling this shipped
@@ -12,3 +12,9 @@ declare module '*.webp' {
12
12
  const src: string;
13
13
  export default src;
14
14
  }
15
+ // Video asset imports (announcement demo recordings). Inlined as data URIs by
16
+ // the single-file build (assetsInlineLimit), so the import resolves to a string.
17
+ declare module '*.webm' {
18
+ const src: string;
19
+ export default src;
20
+ }
@@ -224,6 +224,7 @@ export function useAIChat({
224
224
  }, []);
225
225
 
226
226
  const setSessionId = useCallback((sessionId: string | null) => {
227
+ sessionIdRef.current = sessionId;
227
228
  setThread(prev => ({ ...prev, sessionId }));
228
229
  }, []);
229
230
 
@@ -486,11 +487,12 @@ export function useAIChat({
486
487
  if (abortRef.current) {
487
488
  abortRef.current.abort();
488
489
  abortRef.current = null;
490
+ pendingAbortRef.current = postServerAbort();
489
491
  }
490
492
  setSessionId(null);
491
493
  setIsCreatingSession(false);
492
494
  setIsStreaming(false);
493
- }, [setSessionId]);
495
+ }, [postServerAbort, setSessionId]);
494
496
 
495
497
  const resetThread = useCallback(() => {
496
498
  sessionEpochRef.current += 1;
@@ -498,12 +500,13 @@ export function useAIChat({
498
500
  if (abortRef.current) {
499
501
  abortRef.current.abort();
500
502
  abortRef.current = null;
503
+ pendingAbortRef.current = postServerAbort();
501
504
  }
502
505
  setThread(createThread(threadTitle));
503
506
  setIsCreatingSession(false);
504
507
  setIsStreaming(false);
505
508
  setError(null);
506
- }, [threadTitle]);
509
+ }, [postServerAbort, threadTitle]);
507
510
 
508
511
  useEffect(() => {
509
512
  return () => {
@@ -0,0 +1,47 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import type { AIProviderOption } from '../utils/aiProvider';
3
+
4
+ /**
5
+ * Explicit AI provider activation for lazily-initialized providers.
6
+ *
7
+ * Codex model discovery is deferred until a session starts (it spawns a
8
+ * `codex app-server` process — see #1144), so /api/ai/capabilities advertises
9
+ * static fallback metadata until then. This hook lets the apps run that same
10
+ * deferred initializer on an explicit user gesture — opening the Ask AI
11
+ * surface, or switching the provider picker — via
12
+ * `GET /api/ai/capabilities?activate=<providerId>`, then merge the refreshed
13
+ * provider metadata (real model list + reasoning efforts) back into app state.
14
+ *
15
+ * Single-flight per provider id: repeated gestures don't re-fetch (activation
16
+ * is idempotent server-side anyway), but a failed fetch un-latches so a later
17
+ * gesture can retry. Providers without deferred initialization are a server-side
18
+ * no-op, so callers don't need to know which providers are lazy.
19
+ */
20
+ export interface ActivatedAIProvider extends AIProviderOption {
21
+ capabilities: Record<string, boolean>;
22
+ }
23
+
24
+ export function useAIProviderActivation(options: {
25
+ onCapabilities: (providers: ActivatedAIProvider[], defaultProvider: string | null) => void;
26
+ }) {
27
+ const activatedRef = useRef<Set<string>>(new Set());
28
+ const onCapabilitiesRef = useRef(options.onCapabilities);
29
+ onCapabilitiesRef.current = options.onCapabilities;
30
+
31
+ return useCallback((providerId: string | null | undefined) => {
32
+ if (!providerId || activatedRef.current.has(providerId)) return;
33
+ activatedRef.current.add(providerId);
34
+ fetch(`/api/ai/capabilities?activate=${encodeURIComponent(providerId)}`)
35
+ .then(res => (res.ok ? res.json() : null))
36
+ .then(data => {
37
+ if (!data?.available) {
38
+ activatedRef.current.delete(providerId);
39
+ return;
40
+ }
41
+ onCapabilitiesRef.current(data.providers ?? [], data.defaultProvider ?? null);
42
+ })
43
+ .catch(() => {
44
+ activatedRef.current.delete(providerId);
45
+ });
46
+ }, []);
47
+ }
@@ -99,9 +99,13 @@ export function useAIProviderConfig({
99
99
  }
100
100
  const reasoningEffort = model ? (reasoningEffortByModel[model] ?? null) : null;
101
101
  const next = { ...prev, providerId, model, reasoningEffort, reasoningEffortByModel };
102
+ // Only an explicit model pick is persisted. A resolver-derived model
103
+ // (e.g. the static fallback a lazily-discovered provider advertises
104
+ // before activation) must never overwrite the user's saved preference
105
+ // — the session request may fall back, the cookie may not.
102
106
  saveAIProviderSelection({
103
107
  providerId: next.providerId,
104
- model: next.model,
108
+ model: config.model !== undefined ? next.model : null,
105
109
  origin,
106
110
  settings: saved,
107
111
  });