@fresh-editor/fresh-editor 0.1.69 → 0.1.71

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.
@@ -89,122 +89,108 @@ interface ThemeField {
89
89
  }
90
90
 
91
91
  // =============================================================================
92
- // Theme Field Metadata
92
+ // Theme Schema (loaded dynamically from Rust)
93
93
  // =============================================================================
94
94
 
95
- const THEME_SECTIONS: ThemeSection[] = [
96
- {
97
- name: "editor",
98
- displayName: "Editor",
99
- description: "Main editor area colors",
100
- fields: [
101
- { key: "bg", displayName: "Background", description: "Editor background color", section: "editor" },
102
- { key: "fg", displayName: "Foreground", description: "Default text color", section: "editor" },
103
- { key: "cursor", displayName: "Cursor", description: "Cursor color", section: "editor" },
104
- { key: "inactive_cursor", displayName: "Inactive Cursor", description: "Cursor color in unfocused splits", section: "editor" },
105
- { key: "selection_bg", displayName: "Selection Background", description: "Selected text background", section: "editor" },
106
- { key: "current_line_bg", displayName: "Current Line Background", description: "Background of the line containing cursor", section: "editor" },
107
- { key: "line_number_fg", displayName: "Line Number Foreground", description: "Line number text color", section: "editor" },
108
- { key: "line_number_bg", displayName: "Line Number Background", description: "Line number gutter background", section: "editor" },
109
- ],
110
- },
111
- {
112
- name: "ui",
113
- displayName: "UI Elements",
114
- description: "User interface colors (tabs, menus, status bar, etc.)",
115
- fields: [
116
- { key: "tab_active_fg", displayName: "Active Tab Foreground", description: "Active tab text color", section: "ui" },
117
- { key: "tab_active_bg", displayName: "Active Tab Background", description: "Active tab background color", section: "ui" },
118
- { key: "tab_inactive_fg", displayName: "Inactive Tab Foreground", description: "Inactive tab text color", section: "ui" },
119
- { key: "tab_inactive_bg", displayName: "Inactive Tab Background", description: "Inactive tab background color", section: "ui" },
120
- { key: "tab_separator_bg", displayName: "Tab Separator", description: "Tab bar separator color", section: "ui" },
121
- { key: "tab_close_hover_fg", displayName: "Tab Close Hover", description: "Tab close button hover color", section: "ui" },
122
- { key: "tab_hover_bg", displayName: "Tab Hover Background", description: "Tab hover background color", section: "ui" },
123
- { key: "menu_bg", displayName: "Menu Background", description: "Menu bar background", section: "ui" },
124
- { key: "menu_fg", displayName: "Menu Foreground", description: "Menu bar text color", section: "ui" },
125
- { key: "menu_active_bg", displayName: "Menu Active Background", description: "Active menu item background", section: "ui" },
126
- { key: "menu_active_fg", displayName: "Menu Active Foreground", description: "Active menu item text color", section: "ui" },
127
- { key: "menu_dropdown_bg", displayName: "Menu Dropdown Background", description: "Dropdown menu background", section: "ui" },
128
- { key: "menu_dropdown_fg", displayName: "Menu Dropdown Foreground", description: "Dropdown menu text color", section: "ui" },
129
- { key: "menu_highlight_bg", displayName: "Menu Highlight Background", description: "Highlighted menu item background", section: "ui" },
130
- { key: "menu_highlight_fg", displayName: "Menu Highlight Foreground", description: "Highlighted menu item text color", section: "ui" },
131
- { key: "menu_border_fg", displayName: "Menu Border", description: "Menu border color", section: "ui" },
132
- { key: "menu_separator_fg", displayName: "Menu Separator", description: "Menu separator line color", section: "ui" },
133
- { key: "menu_hover_bg", displayName: "Menu Hover Background", description: "Menu item hover background", section: "ui" },
134
- { key: "menu_hover_fg", displayName: "Menu Hover Foreground", description: "Menu item hover text color", section: "ui" },
135
- { key: "menu_disabled_fg", displayName: "Menu Disabled Foreground", description: "Disabled menu item text color", section: "ui" },
136
- { key: "menu_disabled_bg", displayName: "Menu Disabled Background", description: "Disabled menu item background", section: "ui" },
137
- { key: "status_bar_fg", displayName: "Status Bar Foreground", description: "Status bar text color", section: "ui" },
138
- { key: "status_bar_bg", displayName: "Status Bar Background", description: "Status bar background color", section: "ui" },
139
- { key: "prompt_fg", displayName: "Prompt Foreground", description: "Command prompt text color", section: "ui" },
140
- { key: "prompt_bg", displayName: "Prompt Background", description: "Command prompt background", section: "ui" },
141
- { key: "prompt_selection_fg", displayName: "Prompt Selection Foreground", description: "Prompt selected text color", section: "ui" },
142
- { key: "prompt_selection_bg", displayName: "Prompt Selection Background", description: "Prompt selection background", section: "ui" },
143
- { key: "popup_border_fg", displayName: "Popup Border", description: "Popup window border color", section: "ui" },
144
- { key: "popup_bg", displayName: "Popup Background", description: "Popup window background", section: "ui" },
145
- { key: "popup_selection_bg", displayName: "Popup Selection Background", description: "Popup selected item background", section: "ui" },
146
- { key: "popup_text_fg", displayName: "Popup Text Foreground", description: "Popup window text color", section: "ui" },
147
- { key: "suggestion_bg", displayName: "Suggestion Background", description: "Autocomplete suggestion background", section: "ui" },
148
- { key: "suggestion_selected_bg", displayName: "Suggestion Selected Background", description: "Selected suggestion background", section: "ui" },
149
- { key: "help_bg", displayName: "Help Background", description: "Help panel background", section: "ui" },
150
- { key: "help_fg", displayName: "Help Foreground", description: "Help panel text color", section: "ui" },
151
- { key: "help_key_fg", displayName: "Help Key Foreground", description: "Help keybinding text color", section: "ui" },
152
- { key: "help_separator_fg", displayName: "Help Separator", description: "Help panel separator color", section: "ui" },
153
- { key: "help_indicator_fg", displayName: "Help Indicator Foreground", description: "Help indicator text color", section: "ui" },
154
- { key: "help_indicator_bg", displayName: "Help Indicator Background", description: "Help indicator background", section: "ui" },
155
- { key: "inline_code_bg", displayName: "Inline Code Background", description: "Inline code block background", section: "ui" },
156
- { key: "split_separator_fg", displayName: "Split Separator", description: "Split pane separator color", section: "ui" },
157
- { key: "split_separator_hover_fg", displayName: "Split Separator Hover", description: "Split separator hover color", section: "ui" },
158
- { key: "scrollbar_track_fg", displayName: "Scrollbar Track", description: "Scrollbar track color", section: "ui" },
159
- { key: "scrollbar_thumb_fg", displayName: "Scrollbar Thumb", description: "Scrollbar thumb color", section: "ui" },
160
- { key: "scrollbar_track_hover_fg", displayName: "Scrollbar Track Hover", description: "Scrollbar track hover color", section: "ui" },
161
- { key: "scrollbar_thumb_hover_fg", displayName: "Scrollbar Thumb Hover", description: "Scrollbar thumb hover color", section: "ui" },
162
- { key: "compose_margin_bg", displayName: "Compose Margin Background", description: "Compose mode margin background", section: "ui" },
163
- { key: "semantic_highlight_bg", displayName: "Semantic Highlight Background", description: "Word under cursor highlight", section: "ui" },
164
- { key: "terminal_bg", displayName: "Terminal Background", description: "Embedded terminal background (use Default for transparency)", section: "ui" },
165
- { key: "terminal_fg", displayName: "Terminal Foreground", description: "Embedded terminal default text color", section: "ui" },
166
- ],
167
- },
168
- {
169
- name: "search",
170
- displayName: "Search",
171
- description: "Search result highlighting colors",
172
- fields: [
173
- { key: "match_bg", displayName: "Match Background", description: "Search match background color", section: "search" },
174
- { key: "match_fg", displayName: "Match Foreground", description: "Search match text color", section: "search" },
175
- ],
176
- },
177
- {
178
- name: "diagnostic",
179
- displayName: "Diagnostics",
180
- description: "LSP diagnostic colors (errors, warnings, etc.)",
181
- fields: [
182
- { key: "error_fg", displayName: "Error Foreground", description: "Error message text color", section: "diagnostic" },
183
- { key: "error_bg", displayName: "Error Background", description: "Error highlight background", section: "diagnostic" },
184
- { key: "warning_fg", displayName: "Warning Foreground", description: "Warning message text color", section: "diagnostic" },
185
- { key: "warning_bg", displayName: "Warning Background", description: "Warning highlight background", section: "diagnostic" },
186
- { key: "info_fg", displayName: "Info Foreground", description: "Info message text color", section: "diagnostic" },
187
- { key: "info_bg", displayName: "Info Background", description: "Info highlight background", section: "diagnostic" },
188
- { key: "hint_fg", displayName: "Hint Foreground", description: "Hint message text color", section: "diagnostic" },
189
- { key: "hint_bg", displayName: "Hint Background", description: "Hint highlight background", section: "diagnostic" },
190
- ],
191
- },
192
- {
193
- name: "syntax",
194
- displayName: "Syntax Highlighting",
195
- description: "Code syntax highlighting colors",
196
- fields: [
197
- { key: "keyword", displayName: "Keyword", description: "Language keywords (if, for, fn, etc.)", section: "syntax" },
198
- { key: "string", displayName: "String", description: "String literals", section: "syntax" },
199
- { key: "comment", displayName: "Comment", description: "Code comments", section: "syntax" },
200
- { key: "function", displayName: "Function", description: "Function names", section: "syntax" },
201
- { key: "type", displayName: "Type", description: "Type names", section: "syntax" },
202
- { key: "variable", displayName: "Variable", description: "Variable names", section: "syntax" },
203
- { key: "constant", displayName: "Constant", description: "Constants and literals", section: "syntax" },
204
- { key: "operator", displayName: "Operator", description: "Operators (+, -, =, etc.)", section: "syntax" },
205
- ],
206
- },
207
- ];
95
+ /**
96
+ * Cached theme sections loaded from the API.
97
+ * This is populated on first use and reflects the actual theme structure from Rust.
98
+ */
99
+ let cachedThemeSections: ThemeSection[] | null = null;
100
+
101
+ /**
102
+ * Load theme sections from the Rust API.
103
+ * Parses the raw JSON Schema and resolves $ref references.
104
+ * Uses i18n keys for localized display names.
105
+ */
106
+ function loadThemeSections(): ThemeSection[] {
107
+ if (cachedThemeSections !== null) {
108
+ return cachedThemeSections;
109
+ }
110
+
111
+ const schema = editor.getThemeSchema();
112
+ const defs = schema.$defs || {};
113
+
114
+ // Helper to resolve $ref and get the referenced schema
115
+ const resolveRef = (refStr: string): Record<string, unknown> | null => {
116
+ // $ref format: "#/$defs/TypeName"
117
+ const prefix = "#/$defs/";
118
+ if (refStr.startsWith(prefix)) {
119
+ const typeName = refStr.slice(prefix.length);
120
+ return defs[typeName] as Record<string, unknown> || null;
121
+ }
122
+ return null;
123
+ };
124
+
125
+ const sections: ThemeSection[] = [];
126
+ const properties = schema.properties || {};
127
+
128
+ // Section ordering
129
+ const sectionOrder = ["editor", "ui", "search", "diagnostic", "syntax"];
130
+
131
+ for (const [sectionName, sectionSchema] of Object.entries(properties)) {
132
+ // Skip "name" field - it's not a color section
133
+ if (sectionName === "name") continue;
134
+
135
+ const sectionObj = sectionSchema as Record<string, unknown>;
136
+ const sectionDesc = (sectionObj.description as string) || "";
137
+
138
+ // Resolve $ref to get the actual type definition
139
+ const refStr = sectionObj.$ref as string | undefined;
140
+ const resolvedSchema = refStr ? resolveRef(refStr) : sectionObj;
141
+ if (!resolvedSchema) continue;
142
+
143
+ const sectionProps = resolvedSchema.properties as Record<string, unknown> || {};
144
+ const fields: ThemeFieldDef[] = [];
145
+
146
+ for (const [fieldName, fieldSchema] of Object.entries(sectionProps)) {
147
+ const fieldObj = fieldSchema as Record<string, unknown>;
148
+ const fieldDesc = (fieldObj.description as string) || "";
149
+
150
+ // Generate i18n keys from field names
151
+ const i18nName = `field.${fieldName}`;
152
+ const i18nDesc = `field.${fieldName}_desc`;
153
+
154
+ fields.push({
155
+ key: fieldName,
156
+ displayName: editor.t(i18nName) || fieldDesc || fieldName,
157
+ description: editor.t(i18nDesc) || fieldDesc,
158
+ section: sectionName,
159
+ });
160
+ }
161
+
162
+ // Sort fields alphabetically (use simple comparison to avoid ICU issues in Deno)
163
+ fields.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
164
+
165
+ // Generate i18n keys for section
166
+ const sectionI18nName = `section.${sectionName}`;
167
+ const sectionI18nDesc = `section.${sectionName}_desc`;
168
+
169
+ sections.push({
170
+ name: sectionName,
171
+ displayName: editor.t(sectionI18nName) || sectionDesc || sectionName,
172
+ description: editor.t(sectionI18nDesc) || sectionDesc,
173
+ fields,
174
+ });
175
+ }
176
+
177
+ // Sort sections in logical order
178
+ sections.sort((a, b) => {
179
+ const aIdx = sectionOrder.indexOf(a.name);
180
+ const bIdx = sectionOrder.indexOf(b.name);
181
+ return (aIdx === -1 ? 99 : aIdx) - (bIdx === -1 ? 99 : bIdx);
182
+ });
183
+
184
+ cachedThemeSections = sections;
185
+ return cachedThemeSections;
186
+ }
187
+
188
+ /**
189
+ * Get theme sections (loads from API if not cached)
190
+ */
191
+ function getThemeSections(): ThemeSection[] {
192
+ return loadThemeSections();
193
+ }
208
194
 
209
195
  // =============================================================================
210
196
  // State Management
@@ -234,6 +220,12 @@ interface ThemeEditorState {
234
220
  hasChanges: boolean;
235
221
  /** Available built-in themes */
236
222
  builtinThemes: string[];
223
+ /** Pending save name for overwrite confirmation */
224
+ pendingSaveName: string | null;
225
+ /** Whether current theme is a built-in (requires Save As) */
226
+ isBuiltin: boolean;
227
+ /** Saved cursor field path (for restoring after prompts) */
228
+ savedCursorPath: string | null;
237
229
  }
238
230
 
239
231
  const state: ThemeEditorState = {
@@ -251,6 +243,9 @@ const state: ThemeEditorState = {
251
243
  selectedIndex: 0,
252
244
  hasChanges: false,
253
245
  builtinThemes: [],
246
+ pendingSaveName: null,
247
+ isBuiltin: false,
248
+ savedCursorPath: null,
254
249
  };
255
250
 
256
251
  // =============================================================================
@@ -266,10 +261,26 @@ const colors = {
266
261
  modified: [255, 255, 100] as RGB, // Yellow
267
262
  footer: [100, 100, 100] as RGB, // Gray
268
263
  colorBlock: [200, 200, 200] as RGB, // Light gray for color swatch outline
264
+ selectionBg: [50, 50, 80] as RGB, // Dark blue-gray for selected field
269
265
  };
270
266
 
271
- // Color block character for swatches
272
- const COLOR_BLOCK = "██";
267
+ // =============================================================================
268
+ // Keyboard Shortcuts (defined once, used in mode and i18n)
269
+ // =============================================================================
270
+
271
+ /**
272
+ * Keyboard shortcuts for the theme editor.
273
+ * These are defined once and used both in the mode definition and in the UI hints.
274
+ */
275
+ const SHORTCUTS = {
276
+ open: "C-o",
277
+ save: "C-s",
278
+ save_as: "C-S-s",
279
+ delete: "C-d",
280
+ reload: "C-r",
281
+ close: "C-q",
282
+ help: "F1",
283
+ };
273
284
 
274
285
  // =============================================================================
275
286
  // Mode Definition
@@ -279,18 +290,24 @@ editor.defineMode(
279
290
  "theme-editor",
280
291
  "normal",
281
292
  [
293
+ // Navigation (standard keys that don't conflict with typing)
282
294
  ["Return", "theme_editor_edit_color"],
283
295
  ["Space", "theme_editor_edit_color"],
284
- ["Tab", "theme_editor_toggle_section"],
285
- ["c", "theme_editor_copy_from_builtin"],
286
- ["n", "theme_editor_set_name"],
287
- ["s", "theme_editor_save"],
288
- ["S", "theme_editor_save_as"],
289
- ["d", "theme_editor_set_as_default"],
290
- ["q", "theme_editor_close"],
296
+ ["Tab", "theme_editor_nav_next_section"],
297
+ ["S-Tab", "theme_editor_nav_prev_section"],
298
+ ["Up", "theme_editor_nav_up"],
299
+ ["Down", "theme_editor_nav_down"],
291
300
  ["Escape", "theme_editor_close"],
292
- ["r", "theme_editor_reload"],
293
- ["?", "theme_editor_show_help"],
301
+ [SHORTCUTS.help, "theme_editor_show_help"],
302
+
303
+ // Ctrl+ shortcuts (match common editor conventions)
304
+ [SHORTCUTS.open, "theme_editor_open"],
305
+ [SHORTCUTS.save, "theme_editor_save"],
306
+ [SHORTCUTS.save_as, "theme_editor_save_as"],
307
+ [SHORTCUTS.delete, "theme_editor_delete"],
308
+ [SHORTCUTS.reload, "theme_editor_reload"],
309
+ [SHORTCUTS.close, "theme_editor_close"],
310
+ ["C-h", "theme_editor_show_help"], // Alternative help key
294
311
  ],
295
312
  true // read-only
296
313
  );
@@ -450,7 +467,7 @@ async function loadBuiltinThemes(): Promise<string[]> {
450
467
  }
451
468
 
452
469
  /**
453
- * Load a theme file
470
+ * Load a theme file from built-in themes directory
454
471
  */
455
472
  async function loadThemeFile(name: string): Promise<Record<string, unknown> | null> {
456
473
  const themesDir = findThemesDir();
@@ -465,6 +482,37 @@ async function loadThemeFile(name: string): Promise<Record<string, unknown> | nu
465
482
  }
466
483
  }
467
484
 
485
+ /**
486
+ * Load a user theme file
487
+ */
488
+ async function loadUserThemeFile(name: string): Promise<{ data: Record<string, unknown>; path: string } | null> {
489
+ const userThemesDir = getUserThemesDir();
490
+ const themePath = editor.pathJoin(userThemesDir, `${name}.json`);
491
+
492
+ try {
493
+ const content = await editor.readFile(themePath);
494
+ return { data: JSON.parse(content), path: themePath };
495
+ } catch {
496
+ editor.debug(`Failed to load user theme: ${name}`);
497
+ return null;
498
+ }
499
+ }
500
+
501
+ /**
502
+ * List available user themes
503
+ */
504
+ function listUserThemes(): string[] {
505
+ const userThemesDir = getUserThemesDir();
506
+ try {
507
+ const entries = editor.readDir(userThemesDir);
508
+ return entries
509
+ .filter(e => e.is_file && e.name.endsWith(".json"))
510
+ .map(e => e.name.replace(".json", ""));
511
+ } catch {
512
+ return [];
513
+ }
514
+ }
515
+
468
516
  /**
469
517
  * Get user themes directory
470
518
  * Uses XDG_CONFIG_HOME if set, otherwise falls back to $HOME/.config
@@ -494,16 +542,17 @@ function getUserThemesDir(): string {
494
542
  */
495
543
  function buildVisibleFields(): ThemeField[] {
496
544
  const fields: ThemeField[] = [];
545
+ const themeSections = getThemeSections();
497
546
 
498
- for (const section of THEME_SECTIONS) {
547
+ for (const section of themeSections) {
499
548
  const expanded = state.expandedSections.has(section.name);
500
549
 
501
- // Section header
550
+ // Section header - displayName and description are already translated in getThemeSections()
502
551
  fields.push({
503
552
  def: {
504
553
  key: section.name,
505
- displayName: editor.t(`section.${section.name}`),
506
- description: editor.t(`section.${section.name}_desc`),
554
+ displayName: section.displayName,
555
+ description: section.description,
507
556
  section: section.name,
508
557
  },
509
558
  value: [0, 0, 0], // Placeholder
@@ -519,12 +568,9 @@ function buildVisibleFields(): ThemeField[] {
519
568
  const path = `${section.name}.${fieldDef.key}`;
520
569
  const value = getNestedValue(state.themeData, path) as ColorValue || [128, 128, 128];
521
570
 
571
+ // fieldDef displayName and description are already translated in getThemeSections()
522
572
  fields.push({
523
- def: {
524
- ...fieldDef,
525
- displayName: editor.t(`field.${fieldDef.key}`),
526
- description: editor.t(`field.${fieldDef.key}_desc`),
527
- },
573
+ def: fieldDef,
528
574
  value,
529
575
  path,
530
576
  depth: 1,
@@ -566,6 +612,21 @@ function buildDisplayEntries(): TextPropertyEntry[] {
566
612
  });
567
613
  }
568
614
 
615
+ // Key hints at the top (moved from footer)
616
+ entries.push({
617
+ text: editor.t("panel.nav_hint") + "\n",
618
+ properties: { type: "footer" },
619
+ });
620
+ entries.push({
621
+ text: editor.t("panel.action_hint", SHORTCUTS) + "\n",
622
+ properties: { type: "footer" },
623
+ });
624
+
625
+ entries.push({
626
+ text: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
627
+ properties: { type: "separator" },
628
+ });
629
+
569
630
  entries.push({
570
631
  text: "\n",
571
632
  properties: { type: "blank" },
@@ -603,11 +664,11 @@ function buildDisplayEntries(): TextPropertyEntry[] {
603
664
  properties: { type: "description", path: field.path },
604
665
  });
605
666
 
606
- // Color field with swatch
667
+ // Color field with swatch characters (X for fg preview, space for bg preview)
607
668
  const colorStr = formatColorValue(field.value);
608
669
 
609
670
  entries.push({
610
- text: `${indent} ${field.def.displayName}: ${colorStr}\n`,
671
+ text: `${indent} ${field.def.displayName}: X ${colorStr}\n`,
611
672
  properties: {
612
673
  type: "field",
613
674
  path: field.path,
@@ -623,25 +684,12 @@ function buildDisplayEntries(): TextPropertyEntry[] {
623
684
  });
624
685
  }
625
686
 
626
- // Footer
627
- entries.push({
628
- text: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
629
- properties: { type: "separator" },
630
- });
631
- entries.push({
632
- text: editor.t("panel.nav_hint") + "\n",
633
- properties: { type: "footer" },
634
- });
635
- entries.push({
636
- text: editor.t("panel.action_hint") + "\n",
637
- properties: { type: "footer" },
638
- });
639
-
640
687
  return entries;
641
688
  }
642
689
 
643
690
  /**
644
- * Helper to add a colored overlay
691
+ * Helper to add a colored overlay (foreground color)
692
+ * addOverlay signature: (bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b, extend_to_line_end)
645
693
  */
646
694
  function addColorOverlay(
647
695
  bufferId: number,
@@ -650,7 +698,20 @@ function addColorOverlay(
650
698
  color: RGB,
651
699
  bold: boolean = false
652
700
  ): void {
653
- editor.addOverlay(bufferId, "theme", start, end, color[0], color[1], color[2], false, bold, false);
701
+ editor.addOverlay(bufferId, "theme", start, end, color[0], color[1], color[2], false, bold, false, -1, -1, -1, false);
702
+ }
703
+
704
+ /**
705
+ * Helper to add a background highlight overlay
706
+ * addOverlay signature: (bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b, extend_to_line_end)
707
+ */
708
+ function addBackgroundHighlight(
709
+ bufferId: number,
710
+ start: number,
711
+ end: number,
712
+ bgColor: RGB
713
+ ): void {
714
+ editor.addOverlay(bufferId, "theme-selection", start, end, -1, -1, -1, false, false, false, bgColor[0], bgColor[1], bgColor[2], true);
654
715
  }
655
716
 
656
717
  /**
@@ -669,70 +730,6 @@ function isSpecialColor(value: ColorValue): boolean {
669
730
  return typeof value === "string" && SPECIAL_COLORS.includes(value);
670
731
  }
671
732
 
672
- /**
673
- * Add color swatches using virtual text
674
- */
675
- function addColorSwatches(): void {
676
- if (state.bufferId === null) return;
677
-
678
- // Clear existing swatches
679
- editor.removeVirtualTextsByPrefix(state.bufferId, "theme-swatch-");
680
-
681
- const entries = buildDisplayEntries();
682
- let byteOffset = 0;
683
-
684
- for (const entry of entries) {
685
- const props = entry.properties as Record<string, unknown>;
686
-
687
- if (props.type === "field" && props.colorValue) {
688
- const colorValue = props.colorValue as ColorValue;
689
- const path = props.path as string;
690
-
691
- // Find position after the field name colon
692
- const colonIdx = entry.text.indexOf(":");
693
- if (colonIdx >= 0) {
694
- const swatchPos = byteOffset + getUtf8ByteLength(entry.text.substring(0, colonIdx + 2));
695
- const swatchId = `theme-swatch-${path}`;
696
-
697
- if (isSpecialColor(colorValue)) {
698
- // For Default/Reset, show a placeholder indicator
699
- editor.addVirtualText(
700
- state.bufferId,
701
- swatchId,
702
- swatchPos,
703
- "∅ ", // Empty set symbol to indicate "use default"
704
- 150, // Gray color for the indicator
705
- 150,
706
- 150,
707
- true,
708
- false
709
- );
710
- } else {
711
- const rgb = parseColorToRgb(colorValue);
712
- if (rgb) {
713
- const useBg = isBackgroundColorField(path);
714
-
715
- // Add swatch with a trailing space included in the text
716
- editor.addVirtualText(
717
- state.bufferId,
718
- swatchId,
719
- swatchPos,
720
- useBg ? " " : COLOR_BLOCK + " ", // Include trailing space in swatch text
721
- rgb[0],
722
- rgb[1],
723
- rgb[2],
724
- true,
725
- useBg // use as background color
726
- );
727
- }
728
- }
729
- }
730
- }
731
-
732
- byteOffset += getUtf8ByteLength(entry.text);
733
- }
734
- }
735
-
736
733
  /**
737
734
  * Apply syntax highlighting
738
735
  */
@@ -741,15 +738,26 @@ function applyHighlighting(): void {
741
738
 
742
739
  const bufferId = state.bufferId;
743
740
  editor.clearNamespace(bufferId, "theme");
741
+ editor.clearNamespace(bufferId, "theme-selection");
744
742
 
745
743
  const entries = buildDisplayEntries();
746
744
  let byteOffset = 0;
747
745
 
746
+ // Get current field at cursor to highlight it
747
+ const currentField = getFieldAtCursor();
748
+ const currentFieldPath = currentField?.path;
749
+
748
750
  for (const entry of entries) {
749
751
  const text = entry.text;
750
752
  const textLen = getUtf8ByteLength(text);
751
753
  const props = entry.properties as Record<string, unknown>;
752
754
  const entryType = props.type as string;
755
+ const entryPath = props.path as string | undefined;
756
+
757
+ // Add selection highlight for current field/section
758
+ if (currentFieldPath && entryPath === currentFieldPath && (entryType === "field" || entryType === "section")) {
759
+ addBackgroundHighlight(bufferId, byteOffset, byteOffset + textLen, colors.selectionBg);
760
+ }
753
761
 
754
762
  if (entryType === "title") {
755
763
  addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.sectionHeader, true);
@@ -766,8 +774,26 @@ function applyHighlighting(): void {
766
774
  const nameEnd = byteOffset + getUtf8ByteLength(text.substring(0, colonPos));
767
775
  addColorOverlay(bufferId, byteOffset, nameEnd, colors.fieldName);
768
776
 
769
- // Value - custom color (green)
770
- const valueStart = nameEnd + getUtf8ByteLength(": ");
777
+ // Color the swatch characters with the field's actual color
778
+ // Text format: "FieldName: X #RRGGBB" (X=fg, space=bg)
779
+ const colorValue = props.colorValue as ColorValue;
780
+ const rgb = parseColorToRgb(colorValue);
781
+ if (rgb) {
782
+ // "X" is at colon + 2 (": " = 2 bytes), and is 1 byte
783
+ const swatchFgStart = nameEnd + getUtf8ByteLength(": ");
784
+ const swatchFgEnd = swatchFgStart + 1; // "X" is 1 byte
785
+ addColorOverlay(bufferId, swatchFgStart, swatchFgEnd, rgb);
786
+
787
+ // First space after "X" is the bg swatch, 1 byte
788
+ const swatchBgStart = swatchFgEnd;
789
+ const swatchBgEnd = swatchBgStart + 1;
790
+ // Use background color for the space
791
+ editor.addOverlay(bufferId, "theme", swatchBgStart, swatchBgEnd, -1, -1, -1, false, false, false, rgb[0], rgb[1], rgb[2], false);
792
+ }
793
+
794
+ // Value (hex code) - custom color (green)
795
+ // Format: ": X #RRGGBB" - value starts after "X " (X + 2 spaces)
796
+ const valueStart = nameEnd + getUtf8ByteLength(": X ");
771
797
  addColorOverlay(bufferId, valueStart, byteOffset + textLen, colors.customValue);
772
798
  }
773
799
  } else if (entryType === "separator" || entryType === "footer") {
@@ -776,20 +802,25 @@ function applyHighlighting(): void {
776
802
 
777
803
  byteOffset += textLen;
778
804
  }
779
-
780
- // Add color swatches
781
- addColorSwatches();
782
805
  }
783
806
 
784
807
  /**
785
- * Update display
808
+ * Update display (preserves cursor position)
786
809
  */
787
810
  function updateDisplay(): void {
788
811
  if (state.bufferId === null) return;
789
812
 
813
+ // Save current field path before updating
814
+ const currentPath = getCurrentFieldPath();
815
+
790
816
  const entries = buildDisplayEntries();
791
817
  editor.setVirtualBufferContent(state.bufferId, entries);
792
818
  applyHighlighting();
819
+
820
+ // Restore cursor to the same field if possible
821
+ if (currentPath) {
822
+ moveCursorToField(currentPath);
823
+ }
793
824
  }
794
825
 
795
826
  // =============================================================================
@@ -814,66 +845,87 @@ function getFieldAtCursor(): ThemeField | null {
814
845
  }
815
846
 
816
847
  /**
817
- * Start color editing prompt
848
+ * Get field by path
818
849
  */
819
- function editColorField(field: ThemeField): void {
820
- const currentValue = formatColorValue(field.value);
821
-
822
- // Use startPromptWithInitial to pre-fill with current value
823
- editor.startPromptWithInitial(editor.t("prompt.color_input", { field: field.def.displayName }), `theme-color-${field.path}`, currentValue);
850
+ function getFieldByPath(path: string): ThemeField | null {
851
+ return state.visibleFields.find(f => f.path === path) || null;
852
+ }
824
853
 
825
- // Build suggestions with named colors and current value
854
+ /**
855
+ * Build color suggestions for a field
856
+ */
857
+ function buildColorSuggestions(field: ThemeField): PromptSuggestion[] {
858
+ const currentValue = formatColorValue(field.value);
826
859
  const suggestions: PromptSuggestion[] = [
827
- {
828
- text: currentValue,
829
- description: editor.t("suggestion.current"),
830
- value: currentValue,
831
- },
860
+ { text: currentValue, description: editor.t("suggestion.current"), value: currentValue },
832
861
  ];
833
862
 
834
- // Add special colors first (Default/Reset for terminal transparency)
863
+ // Add special colors (Default/Reset for terminal transparency)
835
864
  for (const name of SPECIAL_COLORS) {
836
- suggestions.push({
837
- text: name,
838
- description: editor.t("suggestion.terminal_native"),
839
- value: name,
840
- });
865
+ suggestions.push({ text: name, description: editor.t("suggestion.terminal_native"), value: name });
841
866
  }
842
867
 
843
- // Add named colors as suggestions with hex format
868
+ // Add named colors with hex format
844
869
  for (const name of NAMED_COLOR_LIST) {
845
870
  const rgb = NAMED_COLORS[name];
846
871
  const hexValue = rgbToHex(rgb[0], rgb[1], rgb[2]);
847
- suggestions.push({
848
- text: name,
849
- description: hexValue,
850
- value: name,
851
- });
872
+ suggestions.push({ text: name, description: hexValue, value: name });
852
873
  }
853
874
 
854
- editor.setPromptSuggestions(suggestions);
875
+ return suggestions;
876
+ }
877
+
878
+ /**
879
+ * Start color editing prompt
880
+ */
881
+ function editColorField(field: ThemeField): void {
882
+ const currentValue = formatColorValue(field.value);
883
+ editor.startPromptWithInitial(
884
+ editor.t("prompt.color_input", { field: field.def.displayName }),
885
+ `theme-color-${field.path}`,
886
+ currentValue
887
+ );
888
+ editor.setPromptSuggestions(buildColorSuggestions(field));
889
+ }
890
+
891
+ interface ParseColorResult {
892
+ value?: ColorValue;
893
+ error?: string;
855
894
  }
856
895
 
857
896
  /**
858
- * Parse color input from user
897
+ * Parse color input from user with detailed error messages
859
898
  */
860
- function parseColorInput(input: string): ColorValue | null {
899
+ function parseColorInput(input: string): ParseColorResult {
861
900
  input = input.trim();
862
901
 
902
+ if (!input) {
903
+ return { error: "empty" };
904
+ }
905
+
863
906
  // Check for special colors (Default/Reset - use terminal's native color)
864
907
  if (SPECIAL_COLORS.includes(input)) {
865
- return input;
908
+ return { value: input };
866
909
  }
867
910
 
868
911
  // Check for named color
869
912
  if (input in NAMED_COLORS) {
870
- return input;
913
+ return { value: input };
871
914
  }
872
915
 
873
916
  // Try to parse as hex color #RRGGBB
874
- const hexResult = hexToRgb(input);
875
- if (hexResult) {
876
- return hexResult;
917
+ if (input.startsWith("#")) {
918
+ const hex = input.slice(1);
919
+ if (hex.length !== 6) {
920
+ return { error: "hex_length" };
921
+ }
922
+ if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
923
+ return { error: "hex_invalid" };
924
+ }
925
+ const hexResult = hexToRgb(input);
926
+ if (hexResult) {
927
+ return { value: hexResult };
928
+ }
877
929
  }
878
930
 
879
931
  // Try to parse as RGB array [r, g, b]
@@ -882,19 +934,46 @@ function parseColorInput(input: string): ColorValue | null {
882
934
  const r = parseInt(rgbMatch[1], 10);
883
935
  const g = parseInt(rgbMatch[2], 10);
884
936
  const b = parseInt(rgbMatch[3], 10);
885
-
886
- if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
887
- return [r, g, b];
937
+ if (r > 255 || g > 255 || b > 255) {
938
+ return { error: "rgb_range" };
888
939
  }
940
+ return { value: [r, g, b] };
889
941
  }
890
942
 
891
- return null;
943
+ // Unknown format
944
+ return { error: "unknown" };
892
945
  }
893
946
 
894
947
  // =============================================================================
895
948
  // Prompt Handlers
896
949
  // =============================================================================
897
950
 
951
+ /**
952
+ * Find best matching color name for partial input
953
+ */
954
+ function findMatchingColor(input: string): string | null {
955
+ const lower = input.toLowerCase();
956
+ // First try exact match
957
+ for (const name of Object.keys(NAMED_COLORS)) {
958
+ if (name.toLowerCase() === lower) return name;
959
+ }
960
+ for (const name of SPECIAL_COLORS) {
961
+ if (name.toLowerCase() === lower) return name;
962
+ }
963
+ // Then try prefix match
964
+ for (const name of Object.keys(NAMED_COLORS)) {
965
+ if (name.toLowerCase().startsWith(lower)) return name;
966
+ }
967
+ for (const name of SPECIAL_COLORS) {
968
+ if (name.toLowerCase().startsWith(lower)) return name;
969
+ }
970
+ // Then try contains match
971
+ for (const name of Object.keys(NAMED_COLORS)) {
972
+ if (name.toLowerCase().includes(lower)) return name;
973
+ }
974
+ return null;
975
+ }
976
+
898
977
  /**
899
978
  * Handle color prompt confirmation
900
979
  */
@@ -906,65 +985,112 @@ globalThis.onThemeColorPromptConfirmed = function(args: {
906
985
  if (!args.prompt_type.startsWith("theme-color-")) return true;
907
986
 
908
987
  const path = args.prompt_type.replace("theme-color-", "");
909
- const newValue = parseColorInput(args.input);
988
+ const field = getFieldByPath(path);
989
+ if (!field) return true;
910
990
 
911
- if (newValue !== null) {
912
- setNestedValue(state.themeData, path, newValue);
991
+ const result = parseColorInput(args.input);
992
+
993
+ if (result.value !== undefined) {
994
+ // Valid color - apply it
995
+ setNestedValue(state.themeData, path, result.value);
913
996
  state.hasChanges = !deepEqual(state.themeData, state.originalThemeData);
914
- updateDisplay();
997
+
998
+ const entries = buildDisplayEntries();
999
+ if (state.bufferId !== null) {
1000
+ editor.setVirtualBufferContent(state.bufferId, entries);
1001
+ applyHighlighting();
1002
+ }
1003
+ moveCursorToField(path);
915
1004
  editor.setStatus(editor.t("status.updated", { path }));
916
1005
  } else {
917
- editor.setStatus(editor.t("status.invalid_color"));
1006
+ // Invalid input - try to find a matching color name
1007
+ const matchedColor = findMatchingColor(args.input);
1008
+ if (matchedColor) {
1009
+ // Found a match - reopen prompt with the matched value
1010
+ editor.startPromptWithInitial(
1011
+ editor.t("prompt.color_input", { field: field.def.displayName }),
1012
+ `theme-color-${path}`,
1013
+ matchedColor
1014
+ );
1015
+ // Rebuild suggestions
1016
+ const suggestions: PromptSuggestion[] = buildColorSuggestions(field);
1017
+ editor.setPromptSuggestions(suggestions);
1018
+ editor.setStatus(editor.t("status.autocompleted", { value: matchedColor }));
1019
+ } else {
1020
+ // No match found - reopen prompt with original input
1021
+ editor.startPromptWithInitial(
1022
+ editor.t("prompt.color_input", { field: field.def.displayName }),
1023
+ `theme-color-${path}`,
1024
+ args.input
1025
+ );
1026
+ const suggestions: PromptSuggestion[] = buildColorSuggestions(field);
1027
+ editor.setPromptSuggestions(suggestions);
1028
+
1029
+ const errorKey = `error.color_${result.error}`;
1030
+ editor.setStatus(editor.t(errorKey, { input: args.input }));
1031
+ }
918
1032
  }
919
1033
 
920
1034
  return true;
921
1035
  };
922
1036
 
923
1037
  /**
924
- * Handle theme name prompt
1038
+ * Handle open theme prompt (both builtin and user themes)
925
1039
  */
926
- globalThis.onThemeNamePromptConfirmed = function(args: {
1040
+ globalThis.onThemeOpenPromptConfirmed = async function(args: {
927
1041
  prompt_type: string;
928
1042
  selected_index: number | null;
929
1043
  input: string;
930
- }): boolean {
931
- if (args.prompt_type !== "theme-name") return true;
932
-
933
- const name = args.input.trim();
934
- if (name) {
935
- state.themeName = name;
936
- state.themeData.name = name;
937
- state.hasChanges = true;
938
- updateDisplay();
939
- editor.setStatus(editor.t("status.name_set", { name }));
940
- }
1044
+ }): Promise<boolean> {
1045
+ if (args.prompt_type !== "theme-open") return true;
941
1046
 
942
- return true;
943
- };
1047
+ const value = args.input.trim();
944
1048
 
945
- /**
946
- * Handle copy from builtin prompt
947
- */
948
- globalThis.onThemeCopyPromptConfirmed = async function(args: {
949
- prompt_type: string;
950
- selected_index: number | null;
951
- input: string;
952
- }): Promise<boolean> {
953
- if (args.prompt_type !== "theme-copy-builtin") return true;
1049
+ // Parse the value to determine if it's user or builtin
1050
+ let isBuiltin = false;
1051
+ let themeName = value;
954
1052
 
955
- const themeName = args.input.trim();
956
- const themeData = await loadThemeFile(themeName);
1053
+ if (value.startsWith("user:")) {
1054
+ themeName = value.slice(5);
1055
+ isBuiltin = false;
1056
+ } else if (value.startsWith("builtin:")) {
1057
+ themeName = value.slice(8);
1058
+ isBuiltin = true;
1059
+ } else {
1060
+ // Fallback: check if it's a builtin theme
1061
+ isBuiltin = state.builtinThemes.includes(value);
1062
+ }
957
1063
 
958
- if (themeData) {
959
- state.themeData = deepClone(themeData);
960
- state.themeName = `${themeName}-custom`;
961
- state.themeData.name = state.themeName;
962
- state.themePath = null; // New theme, not saved yet
963
- state.hasChanges = true;
964
- updateDisplay();
965
- editor.setStatus(editor.t("status.copied", { theme: themeName }));
1064
+ if (isBuiltin) {
1065
+ // Load builtin theme
1066
+ const themeData = await loadThemeFile(themeName);
1067
+ if (themeData) {
1068
+ state.themeData = deepClone(themeData);
1069
+ state.originalThemeData = deepClone(themeData);
1070
+ state.themeName = themeName;
1071
+ state.themePath = null; // No user path for builtin
1072
+ state.isBuiltin = true;
1073
+ state.hasChanges = false;
1074
+ updateDisplay();
1075
+ editor.setStatus(editor.t("status.opened_builtin", { name: themeName }));
1076
+ } else {
1077
+ editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1078
+ }
966
1079
  } else {
967
- editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1080
+ // Load user theme
1081
+ const result = await loadUserThemeFile(themeName);
1082
+ if (result) {
1083
+ state.themeData = deepClone(result.data);
1084
+ state.originalThemeData = deepClone(result.data);
1085
+ state.themeName = themeName;
1086
+ state.themePath = result.path;
1087
+ state.isBuiltin = false;
1088
+ state.hasChanges = false;
1089
+ updateDisplay();
1090
+ editor.setStatus(editor.t("status.loaded", { name: themeName }));
1091
+ } else {
1092
+ editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1093
+ }
968
1094
  }
969
1095
 
970
1096
  return true;
@@ -982,47 +1108,131 @@ globalThis.onThemeSaveAsPromptConfirmed = async function(args: {
982
1108
 
983
1109
  const name = args.input.trim();
984
1110
  if (name) {
1111
+ // Check if theme already exists
1112
+ const userThemesDir = getUserThemesDir();
1113
+ const targetPath = editor.pathJoin(userThemesDir, `${name}.json`);
1114
+
1115
+ if (editor.fileExists(targetPath)) {
1116
+ // Store pending save name for overwrite confirmation
1117
+ state.pendingSaveName = name;
1118
+ editor.startPrompt(editor.t("prompt.overwrite_confirm", { name }), "theme-overwrite-confirm");
1119
+ const suggestions: PromptSuggestion[] = [
1120
+ { text: editor.t("prompt.overwrite_yes"), description: "", value: "overwrite" },
1121
+ { text: editor.t("prompt.overwrite_no"), description: "", value: "cancel" },
1122
+ ];
1123
+ editor.setPromptSuggestions(suggestions);
1124
+ return true;
1125
+ }
1126
+
985
1127
  state.themeName = name;
986
1128
  state.themeData.name = name;
987
- await saveTheme(name);
1129
+ const restorePath = state.savedCursorPath;
1130
+ state.savedCursorPath = null;
1131
+ await saveTheme(name, restorePath);
1132
+ } else {
1133
+ state.savedCursorPath = null;
988
1134
  }
989
1135
 
990
1136
  return true;
991
1137
  };
992
1138
 
993
1139
  /**
994
- * Handle set as default prompt
1140
+ * Handle prompt cancellation
1141
+ */
1142
+ globalThis.onThemePromptCancelled = function(args: { prompt_type: string }): boolean {
1143
+ if (!args.prompt_type.startsWith("theme-")) return true;
1144
+
1145
+ // Clear saved cursor path on cancellation
1146
+ state.savedCursorPath = null;
1147
+ state.pendingSaveName = null;
1148
+
1149
+ editor.setStatus(editor.t("status.cancelled"));
1150
+ return true;
1151
+ };
1152
+
1153
+ /**
1154
+ * Handle initial theme selection prompt (when opening editor)
995
1155
  */
996
- globalThis.onThemeSetDefaultPromptConfirmed = async function(args: {
1156
+ globalThis.onThemeSelectInitialPromptConfirmed = async function(args: {
997
1157
  prompt_type: string;
998
1158
  selected_index: number | null;
999
1159
  input: string;
1000
1160
  }): Promise<boolean> {
1001
- if (args.prompt_type !== "theme-set-default") return true;
1161
+ if (args.prompt_type !== "theme-select-initial") return true;
1002
1162
 
1003
- const themeName = args.input.trim();
1004
- if (themeName) {
1005
- await setThemeAsDefault(themeName);
1163
+ const value = args.input.trim();
1164
+
1165
+ // Parse the value to determine if it's user or builtin
1166
+ let isBuiltin = false;
1167
+ let themeName = value;
1168
+
1169
+ if (value.startsWith("user:")) {
1170
+ themeName = value.slice(5);
1171
+ isBuiltin = false;
1172
+ } else if (value.startsWith("builtin:")) {
1173
+ themeName = value.slice(8);
1174
+ isBuiltin = true;
1175
+ } else {
1176
+ // Fallback: check if it's a builtin theme
1177
+ isBuiltin = state.builtinThemes.includes(value);
1006
1178
  }
1007
1179
 
1008
- return true;
1009
- };
1180
+ editor.setStatus(editor.t("status.loading"));
1181
+
1182
+ if (isBuiltin) {
1183
+ // Load builtin theme
1184
+ const themeData = await loadThemeFile(themeName);
1185
+ if (themeData) {
1186
+ state.themeData = deepClone(themeData);
1187
+ state.originalThemeData = deepClone(themeData);
1188
+ state.themeName = themeName;
1189
+ state.themePath = null; // No user path for builtin
1190
+ state.isBuiltin = true;
1191
+ state.hasChanges = false;
1192
+ } else {
1193
+ // Fallback to default theme if load failed
1194
+ state.themeData = createDefaultTheme();
1195
+ state.originalThemeData = deepClone(state.themeData);
1196
+ state.themeName = themeName;
1197
+ state.themePath = null;
1198
+ state.isBuiltin = true;
1199
+ state.hasChanges = false;
1200
+ }
1201
+ } else {
1202
+ // Load user theme
1203
+ const result = await loadUserThemeFile(themeName);
1204
+ if (result) {
1205
+ state.themeData = deepClone(result.data);
1206
+ state.originalThemeData = deepClone(result.data);
1207
+ state.themeName = themeName;
1208
+ state.themePath = result.path;
1209
+ state.isBuiltin = false;
1210
+ state.hasChanges = false;
1211
+ } else {
1212
+ // Fallback to default theme if load failed
1213
+ state.themeData = createDefaultTheme();
1214
+ state.originalThemeData = deepClone(state.themeData);
1215
+ state.themeName = themeName;
1216
+ state.themePath = null;
1217
+ state.isBuiltin = false;
1218
+ state.hasChanges = false;
1219
+ }
1220
+ }
1221
+
1222
+ // Now open the editor with loaded theme
1223
+ await doOpenThemeEditor();
1010
1224
 
1011
- /**
1012
- * Handle prompt cancellation
1013
- */
1014
- globalThis.onThemePromptCancelled = function(args: { prompt_type: string }): boolean {
1015
- if (!args.prompt_type.startsWith("theme-")) return true;
1016
- editor.setStatus(editor.t("status.cancelled"));
1017
1225
  return true;
1018
1226
  };
1019
1227
 
1020
1228
  // Register prompt handlers
1229
+ editor.on("prompt_confirmed", "onThemeSelectInitialPromptConfirmed");
1021
1230
  editor.on("prompt_confirmed", "onThemeColorPromptConfirmed");
1022
- editor.on("prompt_confirmed", "onThemeNamePromptConfirmed");
1023
- editor.on("prompt_confirmed", "onThemeCopyPromptConfirmed");
1231
+ editor.on("prompt_confirmed", "onThemeOpenPromptConfirmed");
1024
1232
  editor.on("prompt_confirmed", "onThemeSaveAsPromptConfirmed");
1025
- editor.on("prompt_confirmed", "onThemeSetDefaultPromptConfirmed");
1233
+ editor.on("prompt_confirmed", "onThemeDiscardPromptConfirmed");
1234
+ editor.on("prompt_confirmed", "onThemeOverwritePromptConfirmed");
1235
+ editor.on("prompt_confirmed", "onThemeDeletePromptConfirmed");
1026
1236
  editor.on("prompt_cancelled", "onThemePromptCancelled");
1027
1237
 
1028
1238
  // =============================================================================
@@ -1031,8 +1241,10 @@ editor.on("prompt_cancelled", "onThemePromptCancelled");
1031
1241
 
1032
1242
  /**
1033
1243
  * Save theme to file
1244
+ * @param name - Theme name to save as
1245
+ * @param restorePath - Optional field path to restore cursor to after save
1034
1246
  */
1035
- async function saveTheme(name?: string): Promise<boolean> {
1247
+ async function saveTheme(name?: string, restorePath?: string | null): Promise<boolean> {
1036
1248
  const themeName = name || state.themeName;
1037
1249
  const userThemesDir = getUserThemesDir();
1038
1250
 
@@ -1056,11 +1268,25 @@ async function saveTheme(name?: string): Promise<boolean> {
1056
1268
 
1057
1269
  state.themePath = themePath;
1058
1270
  state.themeName = themeName;
1271
+ state.isBuiltin = false; // After saving, it's now a user theme
1059
1272
  state.originalThemeData = deepClone(state.themeData);
1060
1273
  state.hasChanges = false;
1061
- updateDisplay();
1062
1274
 
1063
- editor.setStatus(editor.t("status.saved", { path: themePath }));
1275
+ // Update display
1276
+ const entries = buildDisplayEntries();
1277
+ if (state.bufferId !== null) {
1278
+ editor.setVirtualBufferContent(state.bufferId, entries);
1279
+ applyHighlighting();
1280
+ }
1281
+
1282
+ // Restore cursor position if provided
1283
+ if (restorePath) {
1284
+ moveCursorToField(restorePath);
1285
+ }
1286
+
1287
+ // Automatically apply the saved theme
1288
+ editor.applyTheme(themeName);
1289
+ editor.setStatus(editor.t("status.saved_and_applied", { name: themeName }));
1064
1290
  return true;
1065
1291
  } catch (e) {
1066
1292
  editor.setStatus(editor.t("status.save_failed", { error: String(e) }));
@@ -1069,20 +1295,7 @@ async function saveTheme(name?: string): Promise<boolean> {
1069
1295
  }
1070
1296
 
1071
1297
  /**
1072
- * Set a theme as the default in config and apply it immediately
1073
- */
1074
- async function setThemeAsDefault(themeName: string): Promise<void> {
1075
- try {
1076
- // Use the editor API to apply and persist the theme
1077
- editor.applyTheme(themeName);
1078
- editor.setStatus(editor.t("status.default_set", { name: themeName }));
1079
- } catch (e) {
1080
- editor.setStatus(editor.t("status.apply_failed", { error: String(e) }));
1081
- }
1082
- }
1083
-
1084
- /**
1085
- * Create a default/empty theme
1298
+ * Create a default/empty theme
1086
1299
  */
1087
1300
  function createDefaultTheme(): Record<string, unknown> {
1088
1301
  return {
@@ -1174,12 +1387,228 @@ globalThis.onThemeEditorCursorMoved = function(data: {
1174
1387
 
1175
1388
  editor.on("cursor_moved", "onThemeEditorCursorMoved");
1176
1389
 
1390
+ /**
1391
+ * Handle buffer_closed event to reset state when buffer is closed by any means
1392
+ */
1393
+ globalThis.onThemeEditorBufferClosed = function(data: {
1394
+ buffer_id: number;
1395
+ }): void {
1396
+ if (state.bufferId !== null && data.buffer_id === state.bufferId) {
1397
+ // Reset state when our buffer is closed
1398
+ state.isOpen = false;
1399
+ state.bufferId = null;
1400
+ state.splitId = null;
1401
+ state.themeData = {};
1402
+ state.originalThemeData = {};
1403
+ state.hasChanges = false;
1404
+ }
1405
+ };
1406
+
1407
+ editor.on("buffer_closed", "onThemeEditorBufferClosed");
1408
+
1409
+ // =============================================================================
1410
+ // Smart Navigation - Skip Non-Selectable Lines
1411
+ // =============================================================================
1412
+
1413
+ interface SelectableEntry {
1414
+ byteOffset: number;
1415
+ valueByteOffset: number; // Position at the value (after "field: ")
1416
+ index: number;
1417
+ isSection: boolean;
1418
+ path: string;
1419
+ }
1420
+
1421
+ /**
1422
+ * Get byte offsets for all selectable entries (fields and sections)
1423
+ */
1424
+ function getSelectableEntries(): SelectableEntry[] {
1425
+ const entries = buildDisplayEntries();
1426
+ const selectableEntries: SelectableEntry[] = [];
1427
+ let byteOffset = 0;
1428
+
1429
+ for (const entry of entries) {
1430
+ const props = entry.properties as Record<string, unknown>;
1431
+ const entryType = props.type as string;
1432
+ const path = (props.path as string) || "";
1433
+
1434
+ // Only fields and sections are selectable (they have index property)
1435
+ if ((entryType === "field" || entryType === "section") && typeof props.index === "number") {
1436
+ // For fields, calculate position at the color value (after "FieldName: X ")
1437
+ let valueByteOffset = byteOffset;
1438
+ if (entryType === "field") {
1439
+ const colonIdx = entry.text.indexOf(":");
1440
+ if (colonIdx >= 0) {
1441
+ // Position at the hex value, after ": X " (colon + space + X + 2 spaces = 5 chars)
1442
+ valueByteOffset = byteOffset + getUtf8ByteLength(entry.text.substring(0, colonIdx + 5));
1443
+ }
1444
+ }
1445
+
1446
+ selectableEntries.push({
1447
+ byteOffset,
1448
+ valueByteOffset,
1449
+ index: props.index as number,
1450
+ isSection: entryType === "section",
1451
+ path,
1452
+ });
1453
+ }
1454
+
1455
+ byteOffset += getUtf8ByteLength(entry.text);
1456
+ }
1457
+
1458
+ return selectableEntries;
1459
+ }
1460
+
1461
+ /**
1462
+ * Get the current selectable entry index based on cursor position
1463
+ */
1464
+ function getCurrentSelectableIndex(): number {
1465
+ if (state.bufferId === null) return -1;
1466
+
1467
+ const props = editor.getTextPropertiesAtCursor(state.bufferId);
1468
+ if (props.length > 0 && typeof props[0].index === "number") {
1469
+ return props[0].index as number;
1470
+ }
1471
+ return -1;
1472
+ }
1473
+
1474
+ /**
1475
+ * Get the current field path at cursor
1476
+ */
1477
+ function getCurrentFieldPath(): string | null {
1478
+ if (state.bufferId === null) return null;
1479
+
1480
+ const props = editor.getTextPropertiesAtCursor(state.bufferId);
1481
+ if (props.length > 0 && typeof props[0].path === "string") {
1482
+ return props[0].path as string;
1483
+ }
1484
+ return null;
1485
+ }
1486
+
1487
+ /**
1488
+ * Move cursor to a field by path (positions at value for fields)
1489
+ */
1490
+ function moveCursorToField(path: string): void {
1491
+ if (state.bufferId === null) return;
1492
+
1493
+ const selectableEntries = getSelectableEntries();
1494
+ for (const entry of selectableEntries) {
1495
+ if (entry.path === path) {
1496
+ // Use valueByteOffset for fields, byteOffset for sections
1497
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1498
+ editor.setBufferCursor(state.bufferId, targetOffset);
1499
+ return;
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * Navigate to the next selectable field/section
1506
+ */
1507
+ globalThis.theme_editor_nav_down = function(): void {
1508
+ if (state.bufferId === null) return;
1509
+
1510
+ const selectableEntries = getSelectableEntries();
1511
+ const currentIndex = getCurrentSelectableIndex();
1512
+
1513
+ // Find next selectable entry after current
1514
+ for (const entry of selectableEntries) {
1515
+ if (entry.index > currentIndex) {
1516
+ // Use valueByteOffset for fields, byteOffset for sections
1517
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1518
+ editor.setBufferCursor(state.bufferId, targetOffset);
1519
+ return;
1520
+ }
1521
+ }
1522
+
1523
+ // Already at last selectable, stay there
1524
+ editor.setStatus(editor.t("status.at_last_field"));
1525
+ };
1526
+
1527
+ /**
1528
+ * Navigate to the previous selectable field/section
1529
+ */
1530
+ globalThis.theme_editor_nav_up = function(): void {
1531
+ if (state.bufferId === null) return;
1532
+
1533
+ const selectableEntries = getSelectableEntries();
1534
+ const currentIndex = getCurrentSelectableIndex();
1535
+
1536
+ // Find previous selectable entry before current
1537
+ for (let i = selectableEntries.length - 1; i >= 0; i--) {
1538
+ const entry = selectableEntries[i];
1539
+ if (entry.index < currentIndex) {
1540
+ // Use valueByteOffset for fields, byteOffset for sections
1541
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1542
+ editor.setBufferCursor(state.bufferId, targetOffset);
1543
+ return;
1544
+ }
1545
+ }
1546
+
1547
+ // Already at first selectable, stay there
1548
+ editor.setStatus(editor.t("status.at_first_field"));
1549
+ };
1550
+
1551
+ /**
1552
+ * Navigate to next element (Tab) - includes both fields and sections
1553
+ */
1554
+ globalThis.theme_editor_nav_next_section = function(): void {
1555
+ if (state.bufferId === null) return;
1556
+
1557
+ const selectableEntries = getSelectableEntries();
1558
+ const currentIndex = getCurrentSelectableIndex();
1559
+
1560
+ // Find next selectable entry after current
1561
+ for (const entry of selectableEntries) {
1562
+ if (entry.index > currentIndex) {
1563
+ // Use valueByteOffset for fields, byteOffset for sections
1564
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1565
+ editor.setBufferCursor(state.bufferId, targetOffset);
1566
+ return;
1567
+ }
1568
+ }
1569
+
1570
+ // Wrap to first entry
1571
+ if (selectableEntries.length > 0) {
1572
+ const entry = selectableEntries[0];
1573
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1574
+ editor.setBufferCursor(state.bufferId, targetOffset);
1575
+ }
1576
+ };
1577
+
1578
+ /**
1579
+ * Navigate to previous element (Shift+Tab) - includes both fields and sections
1580
+ */
1581
+ globalThis.theme_editor_nav_prev_section = function(): void {
1582
+ if (state.bufferId === null) return;
1583
+
1584
+ const selectableEntries = getSelectableEntries();
1585
+ const currentIndex = getCurrentSelectableIndex();
1586
+
1587
+ // Find previous selectable entry before current
1588
+ for (let i = selectableEntries.length - 1; i >= 0; i--) {
1589
+ const entry = selectableEntries[i];
1590
+ if (entry.index < currentIndex) {
1591
+ // Use valueByteOffset for fields, byteOffset for sections
1592
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1593
+ editor.setBufferCursor(state.bufferId, targetOffset);
1594
+ return;
1595
+ }
1596
+ }
1597
+
1598
+ // Wrap to last entry
1599
+ if (selectableEntries.length > 0) {
1600
+ const entry = selectableEntries[selectableEntries.length - 1];
1601
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1602
+ editor.setBufferCursor(state.bufferId, targetOffset);
1603
+ }
1604
+ };
1605
+
1177
1606
  // =============================================================================
1178
1607
  // Public Commands
1179
1608
  // =============================================================================
1180
1609
 
1181
1610
  /**
1182
- * Open the theme editor
1611
+ * Open the theme editor - prompts user to select theme first
1183
1612
  */
1184
1613
  globalThis.open_theme_editor = async function(): Promise<void> {
1185
1614
  if (state.isOpen) {
@@ -1187,8 +1616,6 @@ globalThis.open_theme_editor = async function(): Promise<void> {
1187
1616
  return;
1188
1617
  }
1189
1618
 
1190
- editor.setStatus(editor.t("status.loading"));
1191
-
1192
1619
  // Save context
1193
1620
  state.sourceSplitId = editor.getActiveSplitId();
1194
1621
  state.sourceBufferId = editor.getActiveBufferId();
@@ -1196,13 +1623,52 @@ globalThis.open_theme_editor = async function(): Promise<void> {
1196
1623
  // Load available themes
1197
1624
  state.builtinThemes = await loadBuiltinThemes();
1198
1625
 
1199
- // Create default theme data
1200
- state.themeData = createDefaultTheme();
1201
- state.originalThemeData = deepClone(state.themeData);
1202
- state.themeName = "custom";
1203
- state.themePath = null;
1204
- state.hasChanges = false;
1626
+ // Get current theme name from config
1627
+ const config = editor.getConfig() as Record<string, unknown>;
1628
+ const currentThemeName = (config?.theme as string) || "dark";
1629
+
1630
+ // Prompt user to select which theme to edit
1631
+ editor.startPrompt(editor.t("prompt.select_theme_to_edit"), "theme-select-initial");
1632
+
1633
+ const suggestions: PromptSuggestion[] = [];
1634
+
1635
+ // Add user themes first
1636
+ const userThemes = listUserThemes();
1637
+ for (const name of userThemes) {
1638
+ const isCurrent = name === currentThemeName;
1639
+ suggestions.push({
1640
+ text: name,
1641
+ description: isCurrent ? editor.t("suggestion.user_theme_current") : editor.t("suggestion.user_theme"),
1642
+ value: `user:${name}`,
1643
+ });
1644
+ }
1645
+
1646
+ // Add built-in themes
1647
+ for (const name of state.builtinThemes) {
1648
+ const isCurrent = name === currentThemeName;
1649
+ suggestions.push({
1650
+ text: name,
1651
+ description: isCurrent ? editor.t("suggestion.builtin_theme_current") : editor.t("suggestion.builtin_theme"),
1652
+ value: `builtin:${name}`,
1653
+ });
1654
+ }
1655
+
1656
+ // Sort suggestions to put current theme first
1657
+ suggestions.sort((a, b) => {
1658
+ const aIsCurrent = a.description.includes("current");
1659
+ const bIsCurrent = b.description.includes("current");
1660
+ if (aIsCurrent && !bIsCurrent) return -1;
1661
+ if (!aIsCurrent && bIsCurrent) return 1;
1662
+ return 0;
1663
+ });
1205
1664
 
1665
+ editor.setPromptSuggestions(suggestions);
1666
+ };
1667
+
1668
+ /**
1669
+ * Actually open the theme editor with loaded theme data
1670
+ */
1671
+ async function doOpenThemeEditor(): Promise<void> {
1206
1672
  // Build initial entries
1207
1673
  const entries = buildDisplayEntries();
1208
1674
 
@@ -1222,14 +1688,12 @@ globalThis.open_theme_editor = async function(): Promise<void> {
1222
1688
  state.bufferId = bufferId;
1223
1689
  state.splitId = null;
1224
1690
 
1225
- editor.setContext("theme-editor", true);
1226
-
1227
1691
  applyHighlighting();
1228
1692
  editor.setStatus(editor.t("status.ready"));
1229
1693
  } else {
1230
1694
  editor.setStatus(editor.t("status.open_failed"));
1231
1695
  }
1232
- };
1696
+ }
1233
1697
 
1234
1698
  /**
1235
1699
  * Close the theme editor
@@ -1238,11 +1702,23 @@ globalThis.theme_editor_close = function(): void {
1238
1702
  if (!state.isOpen) return;
1239
1703
 
1240
1704
  if (state.hasChanges) {
1241
- editor.setStatus(editor.t("status.unsaved_discarded"));
1705
+ // Show confirmation prompt before closing with unsaved changes
1706
+ editor.startPrompt(editor.t("prompt.discard_confirm"), "theme-discard-confirm");
1707
+ const suggestions: PromptSuggestion[] = [
1708
+ { text: editor.t("prompt.discard_yes"), description: "", value: "discard" },
1709
+ { text: editor.t("prompt.discard_no"), description: "", value: "keep" },
1710
+ ];
1711
+ editor.setPromptSuggestions(suggestions);
1712
+ return;
1242
1713
  }
1243
1714
 
1244
- editor.setContext("theme-editor", false);
1715
+ doCloseEditor();
1716
+ };
1245
1717
 
1718
+ /**
1719
+ * Actually close the editor (called after confirmation or when no changes)
1720
+ */
1721
+ function doCloseEditor(): void {
1246
1722
  // Close the buffer (this will switch to another buffer in the same split)
1247
1723
  if (state.bufferId !== null) {
1248
1724
  editor.closeBuffer(state.bufferId);
@@ -1257,6 +1733,27 @@ globalThis.theme_editor_close = function(): void {
1257
1733
  state.hasChanges = false;
1258
1734
 
1259
1735
  editor.setStatus(editor.t("status.closed"));
1736
+ }
1737
+
1738
+ /**
1739
+ * Handle discard confirmation prompt
1740
+ */
1741
+ globalThis.onThemeDiscardPromptConfirmed = function(args: {
1742
+ prompt_type: string;
1743
+ selected_index: number | null;
1744
+ input: string;
1745
+ }): boolean {
1746
+ if (args.prompt_type !== "theme-discard-confirm") return true;
1747
+
1748
+ const response = args.input.trim().toLowerCase();
1749
+ if (response === "discard" || args.selected_index === 0) {
1750
+ editor.setStatus(editor.t("status.unsaved_discarded"));
1751
+ doCloseEditor();
1752
+ } else {
1753
+ editor.setStatus(editor.t("status.cancelled"));
1754
+ }
1755
+
1756
+ return false;
1260
1757
  };
1261
1758
 
1262
1759
  /**
@@ -1297,49 +1794,116 @@ globalThis.theme_editor_toggle_section = function(): void {
1297
1794
  };
1298
1795
 
1299
1796
  /**
1300
- * Copy from a built-in theme
1797
+ * Open a theme (builtin or user) for editing
1301
1798
  */
1302
- globalThis.theme_editor_copy_from_builtin = function(): void {
1303
- editor.startPrompt(editor.t("prompt.copy_theme"), "theme-copy-builtin");
1799
+ globalThis.theme_editor_open = function(): void {
1800
+ editor.startPrompt(editor.t("prompt.open_theme"), "theme-open");
1304
1801
 
1305
- const suggestions: PromptSuggestion[] = state.builtinThemes.map(name => ({
1306
- text: name,
1307
- description: editor.t("suggestion.builtin_theme"),
1308
- value: name,
1309
- }));
1802
+ const suggestions: PromptSuggestion[] = [];
1310
1803
 
1311
- editor.setPromptSuggestions(suggestions);
1312
- };
1804
+ // Add user themes first
1805
+ const userThemes = listUserThemes();
1806
+ for (const name of userThemes) {
1807
+ suggestions.push({
1808
+ text: name,
1809
+ description: editor.t("suggestion.user_theme"),
1810
+ value: `user:${name}`,
1811
+ });
1812
+ }
1313
1813
 
1314
- /**
1315
- * Set theme name
1316
- */
1317
- globalThis.theme_editor_set_name = function(): void {
1318
- editor.startPrompt(editor.t("prompt.theme_name"), "theme-name");
1814
+ // Add built-in themes
1815
+ for (const name of state.builtinThemes) {
1816
+ suggestions.push({
1817
+ text: name,
1818
+ description: editor.t("suggestion.builtin_theme"),
1819
+ value: `builtin:${name}`,
1820
+ });
1821
+ }
1319
1822
 
1320
- editor.setPromptSuggestions([{
1321
- text: state.themeName,
1322
- description: editor.t("suggestion.current"),
1323
- value: state.themeName,
1324
- }]);
1823
+ editor.setPromptSuggestions(suggestions);
1325
1824
  };
1326
1825
 
1327
1826
  /**
1328
1827
  * Save theme
1329
1828
  */
1330
1829
  globalThis.theme_editor_save = async function(): Promise<void> {
1331
- if (!state.hasChanges && state.themePath) {
1830
+ // Save cursor path for restoration after save
1831
+ state.savedCursorPath = getCurrentFieldPath();
1832
+
1833
+ // Built-in themes require Save As
1834
+ if (state.isBuiltin) {
1835
+ editor.setStatus(editor.t("status.builtin_requires_save_as"));
1836
+ theme_editor_save_as();
1837
+ return;
1838
+ }
1839
+
1840
+ // If theme has never been saved (no path), trigger "Save As" instead
1841
+ if (!state.themePath) {
1842
+ theme_editor_save_as();
1843
+ return;
1844
+ }
1845
+
1846
+ if (!state.hasChanges) {
1332
1847
  editor.setStatus(editor.t("status.no_changes"));
1333
1848
  return;
1334
1849
  }
1335
1850
 
1336
- await saveTheme();
1851
+ // Check for name collision if name has changed since last save
1852
+ const userThemesDir = getUserThemesDir();
1853
+ const targetPath = editor.pathJoin(userThemesDir, `${state.themeName}.json`);
1854
+
1855
+ if (state.themePath !== targetPath && editor.fileExists(targetPath)) {
1856
+ // File exists with this name - ask for confirmation
1857
+ editor.startPrompt(editor.t("prompt.overwrite_confirm", { name: state.themeName }), "theme-overwrite-confirm");
1858
+ const suggestions: PromptSuggestion[] = [
1859
+ { text: editor.t("prompt.overwrite_yes"), description: "", value: "overwrite" },
1860
+ { text: editor.t("prompt.overwrite_no"), description: "", value: "cancel" },
1861
+ ];
1862
+ editor.setPromptSuggestions(suggestions);
1863
+ return;
1864
+ }
1865
+
1866
+ await saveTheme(undefined, state.savedCursorPath);
1867
+ };
1868
+
1869
+ /**
1870
+ * Handle overwrite confirmation prompt
1871
+ */
1872
+ globalThis.onThemeOverwritePromptConfirmed = async function(args: {
1873
+ prompt_type: string;
1874
+ selected_index: number | null;
1875
+ input: string;
1876
+ }): Promise<boolean> {
1877
+ if (args.prompt_type !== "theme-overwrite-confirm") return true;
1878
+
1879
+ const response = args.input.trim().toLowerCase();
1880
+ if (response === "overwrite" || args.selected_index === 0) {
1881
+ // Use pending name if set (from Save As), otherwise use current name
1882
+ const nameToSave = state.pendingSaveName || state.themeName;
1883
+ state.themeName = nameToSave;
1884
+ state.themeData.name = nameToSave;
1885
+ state.pendingSaveName = null;
1886
+ const restorePath = state.savedCursorPath;
1887
+ state.savedCursorPath = null;
1888
+ await saveTheme(nameToSave, restorePath);
1889
+ } else {
1890
+ state.pendingSaveName = null;
1891
+ state.savedCursorPath = null;
1892
+ editor.setStatus(editor.t("status.cancelled"));
1893
+ }
1894
+
1895
+ return false;
1337
1896
  };
1338
1897
 
1339
1898
  /**
1340
1899
  * Save theme as (new name)
1341
1900
  */
1342
1901
  globalThis.theme_editor_save_as = function(): void {
1902
+ // Save cursor path for restoration after save (if not already saved by theme_editor_save)
1903
+ if (!state.savedCursorPath) {
1904
+ state.savedCursorPath = getCurrentFieldPath();
1905
+ }
1906
+
1343
1907
  editor.startPrompt(editor.t("prompt.save_as"), "theme-save-as");
1344
1908
 
1345
1909
  editor.setPromptSuggestions([{
@@ -1349,34 +1913,6 @@ globalThis.theme_editor_save_as = function(): void {
1349
1913
  }]);
1350
1914
  };
1351
1915
 
1352
- /**
1353
- * Set current theme as default
1354
- */
1355
- globalThis.theme_editor_set_as_default = function(): void {
1356
- editor.startPrompt(editor.t("prompt.set_default"), "theme-set-default");
1357
-
1358
- // Suggest current theme and all builtins
1359
- const suggestions: PromptSuggestion[] = [];
1360
-
1361
- if (state.themeName && state.themePath) {
1362
- suggestions.push({
1363
- text: state.themeName,
1364
- description: editor.t("suggestion.current"),
1365
- value: state.themeName,
1366
- });
1367
- }
1368
-
1369
- for (const name of state.builtinThemes) {
1370
- suggestions.push({
1371
- text: name,
1372
- description: editor.t("suggestion.builtin"),
1373
- value: name,
1374
- });
1375
- }
1376
-
1377
- editor.setPromptSuggestions(suggestions);
1378
- };
1379
-
1380
1916
  /**
1381
1917
  * Reload theme
1382
1918
  */
@@ -1407,11 +1943,68 @@ globalThis.theme_editor_show_help = function(): void {
1407
1943
  editor.setStatus(editor.t("status.help"));
1408
1944
  };
1409
1945
 
1946
+ /**
1947
+ * Delete the current user theme
1948
+ */
1949
+ globalThis.theme_editor_delete = function(): void {
1950
+ // Can only delete saved user themes
1951
+ if (!state.themePath) {
1952
+ editor.setStatus(editor.t("status.cannot_delete_unsaved"));
1953
+ return;
1954
+ }
1955
+
1956
+ // Show confirmation dialog
1957
+ editor.startPrompt(editor.t("prompt.delete_confirm", { name: state.themeName }), "theme-delete-confirm");
1958
+ const suggestions: PromptSuggestion[] = [
1959
+ { text: editor.t("prompt.delete_yes"), description: "", value: "delete" },
1960
+ { text: editor.t("prompt.delete_no"), description: "", value: "cancel" },
1961
+ ];
1962
+ editor.setPromptSuggestions(suggestions);
1963
+ };
1964
+
1965
+ /**
1966
+ * Handle delete confirmation prompt
1967
+ */
1968
+ globalThis.onThemeDeletePromptConfirmed = async function(args: {
1969
+ prompt_type: string;
1970
+ selected_index: number | null;
1971
+ input: string;
1972
+ }): Promise<boolean> {
1973
+ if (args.prompt_type !== "theme-delete-confirm") return true;
1974
+
1975
+ const value = args.input.trim();
1976
+ if (value === "delete" || value === editor.t("prompt.delete_yes")) {
1977
+ if (state.themePath) {
1978
+ try {
1979
+ // Delete the theme file
1980
+ await editor.deleteFile(state.themePath);
1981
+ const deletedName = state.themeName;
1982
+
1983
+ // Reset to default theme
1984
+ state.themeData = createDefaultTheme();
1985
+ state.originalThemeData = deepClone(state.themeData);
1986
+ state.themeName = "custom";
1987
+ state.themePath = null;
1988
+ state.hasChanges = false;
1989
+ updateDisplay();
1990
+
1991
+ editor.setStatus(editor.t("status.deleted", { name: deletedName }));
1992
+ } catch (e) {
1993
+ editor.setStatus(editor.t("status.delete_failed", { error: String(e) }));
1994
+ }
1995
+ }
1996
+ } else {
1997
+ editor.setStatus(editor.t("status.cancelled"));
1998
+ }
1999
+
2000
+ return true;
2001
+ };
2002
+
1410
2003
  // =============================================================================
1411
2004
  // Command Registration
1412
2005
  // =============================================================================
1413
2006
 
1414
- // Main command to open theme editor
2007
+ // Main command to open theme editor (always available)
1415
2008
  editor.registerCommand(
1416
2009
  "%cmd.edit_theme",
1417
2010
  "%cmd.edit_theme_desc",
@@ -1419,76 +2012,21 @@ editor.registerCommand(
1419
2012
  "normal"
1420
2013
  );
1421
2014
 
1422
- // Context-specific commands
1423
- editor.registerCommand(
1424
- "%cmd.close_editor",
1425
- "%cmd.close_editor_desc",
1426
- "theme_editor_close",
1427
- "normal,theme-editor"
1428
- );
1429
-
1430
- editor.registerCommand(
1431
- "%cmd.edit_color",
1432
- "%cmd.edit_color_desc",
1433
- "theme_editor_edit_color",
1434
- "normal,theme-editor"
1435
- );
1436
-
1437
- editor.registerCommand(
1438
- "%cmd.toggle_section",
1439
- "%cmd.toggle_section_desc",
1440
- "theme_editor_toggle_section",
1441
- "normal,theme-editor"
1442
- );
1443
-
1444
- editor.registerCommand(
1445
- "%cmd.copy_builtin",
1446
- "%cmd.copy_builtin_desc",
1447
- "theme_editor_copy_from_builtin",
1448
- "normal,theme-editor"
1449
- );
1450
-
1451
- editor.registerCommand(
1452
- "%cmd.set_name",
1453
- "%cmd.set_name_desc",
1454
- "theme_editor_set_name",
1455
- "normal,theme-editor"
1456
- );
1457
-
1458
- editor.registerCommand(
1459
- "%cmd.save",
1460
- "%cmd.save_desc",
1461
- "theme_editor_save",
1462
- "normal,theme-editor"
1463
- );
1464
-
1465
- editor.registerCommand(
1466
- "%cmd.save_as",
1467
- "%cmd.save_as_desc",
1468
- "theme_editor_save_as",
1469
- "normal,theme-editor"
1470
- );
1471
-
1472
- editor.registerCommand(
1473
- "%cmd.set_default",
1474
- "%cmd.set_default_desc",
1475
- "theme_editor_set_as_default",
1476
- "normal,theme-editor"
1477
- );
1478
-
1479
- editor.registerCommand(
1480
- "%cmd.reload",
1481
- "%cmd.reload_desc",
1482
- "theme_editor_reload",
1483
- "normal,theme-editor"
1484
- );
1485
-
1486
- editor.registerCommand(
1487
- "%cmd.show_help",
1488
- "%cmd.show_help_desc",
1489
- "theme_editor_show_help",
1490
- "normal,theme-editor"
1491
- );
2015
+ // Buffer-scoped commands - only visible when a buffer with mode "theme-editor" is focused
2016
+ // The core automatically checks the focused buffer's mode against command contexts
2017
+ editor.registerCommand("%cmd.close_editor", "%cmd.close_editor_desc", "theme_editor_close", "theme-editor");
2018
+ editor.registerCommand("%cmd.edit_color", "%cmd.edit_color_desc", "theme_editor_edit_color", "theme-editor");
2019
+ editor.registerCommand("%cmd.toggle_section", "%cmd.toggle_section_desc", "theme_editor_toggle_section", "theme-editor");
2020
+ editor.registerCommand("%cmd.open_theme", "%cmd.open_theme_desc", "theme_editor_open", "theme-editor");
2021
+ editor.registerCommand("%cmd.save", "%cmd.save_desc", "theme_editor_save", "theme-editor");
2022
+ editor.registerCommand("%cmd.save_as", "%cmd.save_as_desc", "theme_editor_save_as", "theme-editor");
2023
+ editor.registerCommand("%cmd.reload", "%cmd.reload_desc", "theme_editor_reload", "theme-editor");
2024
+ editor.registerCommand("%cmd.show_help", "%cmd.show_help_desc", "theme_editor_show_help", "theme-editor");
2025
+ editor.registerCommand("%cmd.delete_theme", "%cmd.delete_theme_desc", "theme_editor_delete", "theme-editor");
2026
+ editor.registerCommand("%cmd.nav_up", "%cmd.nav_up_desc", "theme_editor_nav_up", "theme-editor");
2027
+ editor.registerCommand("%cmd.nav_down", "%cmd.nav_down_desc", "theme_editor_nav_down", "theme-editor");
2028
+ editor.registerCommand("%cmd.nav_next", "%cmd.nav_next_desc", "theme_editor_nav_next_section", "theme-editor");
2029
+ editor.registerCommand("%cmd.nav_prev", "%cmd.nav_prev_desc", "theme_editor_nav_prev_section", "theme-editor");
1492
2030
 
1493
2031
  // =============================================================================
1494
2032
  // Plugin Initialization