@involvex/fresh-editor 0.1.76 → 0.1.78

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 (90) hide show
  1. package/bin/CHANGELOG.md +1017 -0
  2. package/bin/LICENSE +117 -0
  3. package/bin/README.md +248 -0
  4. package/bin/fresh.exe +0 -0
  5. package/bin/plugins/README.md +71 -0
  6. package/bin/plugins/audit_mode.i18n.json +821 -0
  7. package/bin/plugins/audit_mode.ts +1810 -0
  8. package/bin/plugins/buffer_modified.i18n.json +67 -0
  9. package/bin/plugins/buffer_modified.ts +281 -0
  10. package/bin/plugins/calculator.i18n.json +93 -0
  11. package/bin/plugins/calculator.ts +770 -0
  12. package/bin/plugins/clangd-lsp.ts +168 -0
  13. package/bin/plugins/clangd_support.i18n.json +223 -0
  14. package/bin/plugins/clangd_support.md +20 -0
  15. package/bin/plugins/clangd_support.ts +325 -0
  16. package/bin/plugins/color_highlighter.i18n.json +145 -0
  17. package/bin/plugins/color_highlighter.ts +304 -0
  18. package/bin/plugins/config-schema.json +768 -0
  19. package/bin/plugins/csharp-lsp.ts +147 -0
  20. package/bin/plugins/csharp_support.i18n.json +80 -0
  21. package/bin/plugins/csharp_support.ts +170 -0
  22. package/bin/plugins/css-lsp.ts +143 -0
  23. package/bin/plugins/diagnostics_panel.i18n.json +236 -0
  24. package/bin/plugins/diagnostics_panel.ts +642 -0
  25. package/bin/plugins/examples/README.md +85 -0
  26. package/bin/plugins/examples/async_demo.ts +165 -0
  27. package/bin/plugins/examples/bookmarks.ts +329 -0
  28. package/bin/plugins/examples/buffer_query_demo.ts +110 -0
  29. package/bin/plugins/examples/git_grep.ts +262 -0
  30. package/bin/plugins/examples/hello_world.ts +93 -0
  31. package/bin/plugins/examples/virtual_buffer_demo.ts +116 -0
  32. package/bin/plugins/find_references.i18n.json +275 -0
  33. package/bin/plugins/find_references.ts +359 -0
  34. package/bin/plugins/git_blame.i18n.json +496 -0
  35. package/bin/plugins/git_blame.ts +707 -0
  36. package/bin/plugins/git_find_file.i18n.json +314 -0
  37. package/bin/plugins/git_find_file.ts +300 -0
  38. package/bin/plugins/git_grep.i18n.json +171 -0
  39. package/bin/plugins/git_grep.ts +191 -0
  40. package/bin/plugins/git_gutter.i18n.json +93 -0
  41. package/bin/plugins/git_gutter.ts +477 -0
  42. package/bin/plugins/git_log.i18n.json +481 -0
  43. package/bin/plugins/git_log.ts +1285 -0
  44. package/bin/plugins/go-lsp.ts +143 -0
  45. package/bin/plugins/html-lsp.ts +145 -0
  46. package/bin/plugins/json-lsp.ts +145 -0
  47. package/bin/plugins/lib/fresh.d.ts +1321 -0
  48. package/bin/plugins/lib/index.ts +24 -0
  49. package/bin/plugins/lib/navigation-controller.ts +214 -0
  50. package/bin/plugins/lib/panel-manager.ts +220 -0
  51. package/bin/plugins/lib/types.ts +72 -0
  52. package/bin/plugins/lib/virtual-buffer-factory.ts +130 -0
  53. package/bin/plugins/live_grep.i18n.json +171 -0
  54. package/bin/plugins/live_grep.ts +422 -0
  55. package/bin/plugins/markdown_compose.i18n.json +223 -0
  56. package/bin/plugins/markdown_compose.ts +630 -0
  57. package/bin/plugins/merge_conflict.i18n.json +821 -0
  58. package/bin/plugins/merge_conflict.ts +1810 -0
  59. package/bin/plugins/path_complete.i18n.json +80 -0
  60. package/bin/plugins/path_complete.ts +165 -0
  61. package/bin/plugins/python-lsp.ts +162 -0
  62. package/bin/plugins/rust-lsp.ts +166 -0
  63. package/bin/plugins/search_replace.i18n.json +405 -0
  64. package/bin/plugins/search_replace.ts +484 -0
  65. package/bin/plugins/test_i18n.i18n.json +67 -0
  66. package/bin/plugins/test_i18n.ts +18 -0
  67. package/bin/plugins/theme_editor.i18n.json +3746 -0
  68. package/bin/plugins/theme_editor.ts +2063 -0
  69. package/bin/plugins/todo_highlighter.i18n.json +184 -0
  70. package/bin/plugins/todo_highlighter.ts +206 -0
  71. package/bin/plugins/typescript-lsp.ts +167 -0
  72. package/bin/plugins/vi_mode.i18n.json +1549 -0
  73. package/bin/plugins/vi_mode.ts +2747 -0
  74. package/bin/plugins/welcome.i18n.json +236 -0
  75. package/bin/plugins/welcome.ts +76 -0
  76. package/bin/themes/dark.json +102 -0
  77. package/bin/themes/dracula.json +62 -0
  78. package/bin/themes/high-contrast.json +102 -0
  79. package/bin/themes/light.json +102 -0
  80. package/bin/themes/nord.json +62 -0
  81. package/bin/themes/nostalgia.json +102 -0
  82. package/bin/themes/solarized-dark.json +62 -0
  83. package/binary-install.js +1 -1
  84. package/dist/bin/fresh.js +9 -0
  85. package/dist/binary-install.js +149 -0
  86. package/dist/binary.js +30 -0
  87. package/dist/fresh-6yhknp07.exe +0 -0
  88. package/dist/install.js +158 -0
  89. package/dist/run-fresh.js +43 -0
  90. package/package.json +7 -2
@@ -0,0 +1,2063 @@
1
+ /// <reference path="../types/fresh.d.ts" />
2
+ const editor = getEditor();
3
+
4
+
5
+ /**
6
+ * Theme Editor Plugin - Interactive color theme editor
7
+ *
8
+ * Provides a visual interface for editing Fresh's color themes with:
9
+ * - Organized display of all theme color fields by section
10
+ * - Inline color swatches showing the actual colors
11
+ * - Color picker supporting both RGB values and named colors
12
+ * - Copy from built-in themes to use as starting point
13
+ * - Save as new theme name
14
+ * - Easy option to set as default theme
15
+ */
16
+
17
+ // =============================================================================
18
+ // Types and Schema
19
+ // =============================================================================
20
+
21
+ type RGB = [number, number, number];
22
+
23
+ /**
24
+ * Named colors supported by Fresh themes
25
+ */
26
+ const NAMED_COLORS: Record<string, RGB> = {
27
+ "Black": [0, 0, 0],
28
+ "Red": [255, 0, 0],
29
+ "Green": [0, 128, 0],
30
+ "Yellow": [255, 255, 0],
31
+ "Blue": [0, 0, 255],
32
+ "Magenta": [255, 0, 255],
33
+ "Cyan": [0, 255, 255],
34
+ "Gray": [128, 128, 128],
35
+ "DarkGray": [169, 169, 169],
36
+ "LightRed": [255, 128, 128],
37
+ "LightGreen": [144, 238, 144],
38
+ "LightYellow": [255, 255, 224],
39
+ "LightBlue": [173, 216, 230],
40
+ "LightMagenta": [255, 128, 255],
41
+ "LightCyan": [224, 255, 255],
42
+ "White": [255, 255, 255],
43
+ };
44
+
45
+ /**
46
+ * Special colors that use the terminal's default (preserves transparency)
47
+ * These don't have RGB values - they tell the terminal to use its native color
48
+ */
49
+ const SPECIAL_COLORS = ["Default", "Reset"];
50
+
51
+ const NAMED_COLOR_LIST = Object.keys(NAMED_COLORS);
52
+ const ALL_COLOR_NAMES = [...NAMED_COLOR_LIST, ...SPECIAL_COLORS];
53
+
54
+ /**
55
+ * Color value - either RGB array or named color string
56
+ */
57
+ type ColorValue = RGB | string;
58
+
59
+ /**
60
+ * Theme section definition
61
+ */
62
+ interface ThemeSection {
63
+ name: string;
64
+ displayName: string;
65
+ description: string;
66
+ fields: ThemeFieldDef[];
67
+ }
68
+
69
+ /**
70
+ * Theme field definition with metadata
71
+ */
72
+ interface ThemeFieldDef {
73
+ key: string;
74
+ displayName: string;
75
+ description: string;
76
+ section: string;
77
+ }
78
+
79
+ /**
80
+ * Theme field with current value
81
+ */
82
+ interface ThemeField {
83
+ def: ThemeFieldDef;
84
+ value: ColorValue;
85
+ path: string;
86
+ depth: number;
87
+ isSection: boolean;
88
+ expanded?: boolean;
89
+ }
90
+
91
+ // =============================================================================
92
+ // Theme Schema (loaded dynamically from Rust)
93
+ // =============================================================================
94
+
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
+ }
194
+
195
+ // =============================================================================
196
+ // State Management
197
+ // =============================================================================
198
+
199
+ interface ThemeEditorState {
200
+ bufferId: number | null;
201
+ splitId: number | null;
202
+ sourceSplitId: number | null;
203
+ sourceBufferId: number | null;
204
+ /** Current theme data */
205
+ themeData: Record<string, unknown>;
206
+ /** Original theme data (for change detection) */
207
+ originalThemeData: Record<string, unknown>;
208
+ /** Theme name */
209
+ themeName: string;
210
+ /** Theme file path (null for new themes) */
211
+ themePath: string | null;
212
+ /** Expanded sections */
213
+ expandedSections: Set<string>;
214
+ /** Visible fields */
215
+ visibleFields: ThemeField[];
216
+ /** Selected field index */
217
+ selectedIndex: number;
218
+ /** Whether there are unsaved changes */
219
+ hasChanges: boolean;
220
+ /** Available built-in themes */
221
+ builtinThemes: string[];
222
+ /** Pending save name for overwrite confirmation */
223
+ pendingSaveName: string | null;
224
+ /** Whether current theme is a built-in (requires Save As) */
225
+ isBuiltin: boolean;
226
+ /** Saved cursor field path (for restoring after prompts) */
227
+ savedCursorPath: string | null;
228
+ }
229
+
230
+ /**
231
+ * Check if the theme editor is currently open.
232
+ * Uses a stateless approach by checking if the buffer actually exists.
233
+ * This handles cases where the buffer was closed externally (e.g., Ctrl+W).
234
+ */
235
+ function isThemeEditorOpen(): boolean {
236
+ if (state.bufferId === null) {
237
+ return false;
238
+ }
239
+ // Check if the buffer actually exists
240
+ const buffers = editor.listBuffers();
241
+ const exists = buffers.some(b => b.id === state.bufferId);
242
+
243
+ // If buffer doesn't exist, reset our stale state
244
+ if (!exists) {
245
+ editor.debug(`Theme editor buffer ${state.bufferId} no longer exists, resetting state`);
246
+ state.bufferId = null;
247
+ state.splitId = null;
248
+ state.themeData = {};
249
+ state.originalThemeData = {};
250
+ state.hasChanges = false;
251
+ }
252
+
253
+ return exists;
254
+ }
255
+
256
+ const state: ThemeEditorState = {
257
+ bufferId: null,
258
+ splitId: null,
259
+ sourceSplitId: null,
260
+ sourceBufferId: null,
261
+ themeData: {},
262
+ originalThemeData: {},
263
+ themeName: "custom",
264
+ themePath: null,
265
+ expandedSections: new Set(["editor", "syntax"]),
266
+ visibleFields: [],
267
+ selectedIndex: 0,
268
+ hasChanges: false,
269
+ builtinThemes: [],
270
+ pendingSaveName: null,
271
+ isBuiltin: false,
272
+ savedCursorPath: null,
273
+ };
274
+
275
+ // =============================================================================
276
+ // Color Definitions for UI
277
+ // =============================================================================
278
+
279
+ const colors = {
280
+ sectionHeader: [255, 200, 100] as RGB, // Gold
281
+ fieldName: [200, 200, 255] as RGB, // Light blue
282
+ defaultValue: [150, 150, 150] as RGB, // Gray
283
+ customValue: [100, 255, 100] as RGB, // Green
284
+ description: [120, 120, 120] as RGB, // Dim gray
285
+ modified: [255, 255, 100] as RGB, // Yellow
286
+ footer: [100, 100, 100] as RGB, // Gray
287
+ colorBlock: [200, 200, 200] as RGB, // Light gray for color swatch outline
288
+ selectionBg: [50, 50, 80] as RGB, // Dark blue-gray for selected field
289
+ };
290
+
291
+ // =============================================================================
292
+ // Keyboard Shortcuts (defined once, used in mode and i18n)
293
+ // =============================================================================
294
+
295
+ /**
296
+ * Keyboard shortcuts for the theme editor.
297
+ * These are defined once and used both in the mode definition and in the UI hints.
298
+ */
299
+ const SHORTCUTS = {
300
+ open: "C-o",
301
+ save: "C-s",
302
+ save_as: "C-S-s",
303
+ delete: "C-d",
304
+ reload: "C-r",
305
+ close: "C-q",
306
+ help: "F1",
307
+ };
308
+
309
+ // =============================================================================
310
+ // Mode Definition
311
+ // =============================================================================
312
+
313
+ editor.defineMode(
314
+ "theme-editor",
315
+ "normal",
316
+ [
317
+ // Navigation (standard keys that don't conflict with typing)
318
+ ["Return", "theme_editor_edit_color"],
319
+ ["Space", "theme_editor_edit_color"],
320
+ ["Tab", "theme_editor_nav_next_section"],
321
+ ["S-Tab", "theme_editor_nav_prev_section"],
322
+ ["Up", "theme_editor_nav_up"],
323
+ ["Down", "theme_editor_nav_down"],
324
+ ["Escape", "theme_editor_close"],
325
+ [SHORTCUTS.help, "theme_editor_show_help"],
326
+
327
+ // Ctrl+ shortcuts (match common editor conventions)
328
+ [SHORTCUTS.open, "theme_editor_open"],
329
+ [SHORTCUTS.save, "theme_editor_save"],
330
+ [SHORTCUTS.save_as, "theme_editor_save_as"],
331
+ [SHORTCUTS.delete, "theme_editor_delete"],
332
+ [SHORTCUTS.reload, "theme_editor_reload"],
333
+ [SHORTCUTS.close, "theme_editor_close"],
334
+ ["C-h", "theme_editor_show_help"], // Alternative help key
335
+ ],
336
+ true // read-only
337
+ );
338
+
339
+ // =============================================================================
340
+ // Utility Functions
341
+ // =============================================================================
342
+
343
+ /**
344
+ * Calculate UTF-8 byte length of a string
345
+ */
346
+ function getUtf8ByteLength(str: string): number {
347
+ let length = 0;
348
+ for (let i = 0; i < str.length; i++) {
349
+ const code = str.charCodeAt(i);
350
+ if (code < 0x80) {
351
+ length += 1;
352
+ } else if (code < 0x800) {
353
+ length += 2;
354
+ } else if (code < 0xD800 || code >= 0xE000) {
355
+ length += 3;
356
+ } else {
357
+ i++;
358
+ length += 4;
359
+ }
360
+ }
361
+ return length;
362
+ }
363
+
364
+ /**
365
+ * Deep clone an object
366
+ */
367
+ function deepClone<T>(obj: T): T {
368
+ return JSON.parse(JSON.stringify(obj));
369
+ }
370
+
371
+ /**
372
+ * Check if two values are deeply equal
373
+ */
374
+ function deepEqual(a: unknown, b: unknown): boolean {
375
+ return JSON.stringify(a) === JSON.stringify(b);
376
+ }
377
+
378
+ /**
379
+ * Parse a color value to RGB
380
+ */
381
+ function parseColorToRgb(value: ColorValue): RGB | null {
382
+ if (Array.isArray(value) && value.length === 3) {
383
+ return value as RGB;
384
+ }
385
+ if (typeof value === "string") {
386
+ return NAMED_COLORS[value] || null;
387
+ }
388
+ return null;
389
+ }
390
+
391
+ /**
392
+ * Convert RGB to hex string
393
+ */
394
+ function rgbToHex(r: number, g: number, b: number): string {
395
+ const toHex = (n: number) => n.toString(16).padStart(2, '0').toUpperCase();
396
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
397
+ }
398
+
399
+ /**
400
+ * Parse hex string to RGB
401
+ */
402
+ function hexToRgb(hex: string): RGB | null {
403
+ const match = hex.match(/^#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})$/);
404
+ if (match) {
405
+ return [
406
+ parseInt(match[1], 16),
407
+ parseInt(match[2], 16),
408
+ parseInt(match[3], 16),
409
+ ];
410
+ }
411
+ return null;
412
+ }
413
+
414
+ /**
415
+ * Format a color value for display (as hex)
416
+ */
417
+ function formatColorValue(value: ColorValue): string {
418
+ if (Array.isArray(value)) {
419
+ return rgbToHex(value[0], value[1], value[2]);
420
+ }
421
+ return String(value);
422
+ }
423
+
424
+ /**
425
+ * Check if a color is a named color (including special colors like Default/Reset)
426
+ */
427
+ function isNamedColor(value: ColorValue): boolean {
428
+ return typeof value === "string" && (value in NAMED_COLORS || SPECIAL_COLORS.includes(value));
429
+ }
430
+
431
+ /**
432
+ * Get nested value from object
433
+ */
434
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
435
+ const parts = path.split(".");
436
+ let current: unknown = obj;
437
+ for (const part of parts) {
438
+ if (current === null || current === undefined) return undefined;
439
+ current = (current as Record<string, unknown>)[part];
440
+ }
441
+ return current;
442
+ }
443
+
444
+ /**
445
+ * Set nested value in object
446
+ */
447
+ function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
448
+ const parts = path.split(".");
449
+ let current: Record<string, unknown> = obj;
450
+ for (let i = 0; i < parts.length - 1; i++) {
451
+ const part = parts[i];
452
+ if (!(part in current) || typeof current[part] !== "object") {
453
+ current[part] = {};
454
+ }
455
+ current = current[part] as Record<string, unknown>;
456
+ }
457
+ current[parts[parts.length - 1]] = value;
458
+ }
459
+
460
+ /**
461
+ * Find themes directory
462
+ */
463
+ function findThemesDir(): string {
464
+ const cwd = editor.getCwd();
465
+ const candidates = [
466
+ editor.pathJoin(cwd, "themes"),
467
+ ];
468
+
469
+ for (const path of candidates) {
470
+ if (editor.fileExists(path)) {
471
+ return path;
472
+ }
473
+ }
474
+
475
+ return candidates[0];
476
+ }
477
+
478
+ /**
479
+ * Load list of available built-in themes
480
+ */
481
+ async function loadBuiltinThemes(): Promise<string[]> {
482
+ try {
483
+ const builtinThemes = editor.getBuiltinThemes();
484
+ return Object.keys(builtinThemes);
485
+ } catch (e) {
486
+ editor.debug(`Failed to load built-in themes list: ${e}`);
487
+ throw e;
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Load a theme file from built-in themes
493
+ */
494
+ async function loadThemeFile(name: string): Promise<Record<string, unknown> | null> {
495
+ try {
496
+ const builtinThemes = editor.getBuiltinThemes();
497
+ if (name in builtinThemes) {
498
+ return JSON.parse(builtinThemes[name]);
499
+ }
500
+ return null;
501
+ } catch (e) {
502
+ editor.debug(`Failed to load theme data for '${name}': ${e}`);
503
+ return null;
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Load a user theme file
509
+ */
510
+ async function loadUserThemeFile(name: string): Promise<{ data: Record<string, unknown>; path: string } | null> {
511
+ const userThemesDir = getUserThemesDir();
512
+ const themePath = editor.pathJoin(userThemesDir, `${name}.json`);
513
+
514
+ try {
515
+ const content = await editor.readFile(themePath);
516
+ return { data: JSON.parse(content), path: themePath };
517
+ } catch {
518
+ editor.debug(`Failed to load user theme: ${name}`);
519
+ return null;
520
+ }
521
+ }
522
+
523
+ /**
524
+ * List available user themes
525
+ */
526
+ function listUserThemes(): string[] {
527
+ const userThemesDir = getUserThemesDir();
528
+ try {
529
+ const entries = editor.readDir(userThemesDir);
530
+ return entries
531
+ .filter(e => e.is_file && e.name.endsWith(".json"))
532
+ .map(e => e.name.replace(".json", ""));
533
+ } catch {
534
+ return [];
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Get user themes directory
540
+ * Uses the API to get the correct path
541
+ */
542
+ function getUserThemesDir(): string {
543
+ // Use the API if available (new method)
544
+ if (typeof editor.getThemesDir === "function") {
545
+ return editor.getThemesDir();
546
+ }
547
+
548
+ // Fallback for older versions (deprecated)
549
+ // Check XDG_CONFIG_HOME first (standard on Linux)
550
+ const xdgConfig = editor.getEnv("XDG_CONFIG_HOME");
551
+ if (xdgConfig) {
552
+ return editor.pathJoin(xdgConfig, "fresh", "themes");
553
+ }
554
+
555
+ // Fall back to $HOME/.config
556
+ const home = editor.getEnv("HOME");
557
+ if (home) {
558
+ return editor.pathJoin(home, ".config", "fresh", "themes");
559
+ }
560
+
561
+ return editor.pathJoin(editor.getCwd(), "themes");
562
+ }
563
+
564
+ // =============================================================================
565
+ // Field Building
566
+ // =============================================================================
567
+
568
+ /**
569
+ * Build visible fields list based on expanded sections
570
+ */
571
+ function buildVisibleFields(): ThemeField[] {
572
+ const fields: ThemeField[] = [];
573
+ const themeSections = getThemeSections();
574
+
575
+ for (const section of themeSections) {
576
+ const expanded = state.expandedSections.has(section.name);
577
+
578
+ // Section header - displayName and description are already translated in getThemeSections()
579
+ fields.push({
580
+ def: {
581
+ key: section.name,
582
+ displayName: section.displayName,
583
+ description: section.description,
584
+ section: section.name,
585
+ },
586
+ value: [0, 0, 0], // Placeholder
587
+ path: section.name,
588
+ depth: 0,
589
+ isSection: true,
590
+ expanded,
591
+ });
592
+
593
+ // Section fields
594
+ if (expanded) {
595
+ for (const fieldDef of section.fields) {
596
+ const path = `${section.name}.${fieldDef.key}`;
597
+ const value = getNestedValue(state.themeData, path) as ColorValue || [128, 128, 128];
598
+
599
+ // fieldDef displayName and description are already translated in getThemeSections()
600
+ fields.push({
601
+ def: fieldDef,
602
+ value,
603
+ path,
604
+ depth: 1,
605
+ isSection: false,
606
+ });
607
+ }
608
+ }
609
+ }
610
+
611
+ return fields;
612
+ }
613
+
614
+ // =============================================================================
615
+ // UI Building
616
+ // =============================================================================
617
+
618
+ /**
619
+ * Build display entries for virtual buffer
620
+ */
621
+ function buildDisplayEntries(): TextPropertyEntry[] {
622
+ const entries: TextPropertyEntry[] = [];
623
+
624
+ // Title
625
+ const modifiedMarker = state.hasChanges ? " " + editor.t("panel.modified") : "";
626
+ entries.push({
627
+ text: `━━━ ${editor.t("panel.title", { name: state.themeName })}${modifiedMarker} ━━━\n`,
628
+ properties: { type: "title" },
629
+ });
630
+
631
+ if (state.themePath) {
632
+ entries.push({
633
+ text: `${editor.t("panel.file", { path: state.themePath })}\n`,
634
+ properties: { type: "file-path" },
635
+ });
636
+ } else {
637
+ entries.push({
638
+ text: editor.t("panel.new_theme") + "\n",
639
+ properties: { type: "file-path" },
640
+ });
641
+ }
642
+
643
+ // Key hints at the top (moved from footer)
644
+ entries.push({
645
+ text: editor.t("panel.nav_hint") + "\n",
646
+ properties: { type: "footer" },
647
+ });
648
+ entries.push({
649
+ text: editor.t("panel.action_hint", SHORTCUTS) + "\n",
650
+ properties: { type: "footer" },
651
+ });
652
+
653
+ entries.push({
654
+ text: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n",
655
+ properties: { type: "separator" },
656
+ });
657
+
658
+ entries.push({
659
+ text: "\n",
660
+ properties: { type: "blank" },
661
+ });
662
+
663
+ // Fields
664
+ state.visibleFields = buildVisibleFields();
665
+
666
+ for (let i = 0; i < state.visibleFields.length; i++) {
667
+ const field = state.visibleFields[i];
668
+ const indent = " ".repeat(field.depth);
669
+
670
+ if (field.isSection) {
671
+ // Section header
672
+ const icon = field.expanded ? "▼" : ">";
673
+ entries.push({
674
+ text: `${indent}${icon} ${field.def.displayName}\n`,
675
+ properties: {
676
+ type: "section",
677
+ path: field.path,
678
+ index: i,
679
+ expanded: field.expanded,
680
+ },
681
+ });
682
+
683
+ // Section description
684
+ entries.push({
685
+ text: `${indent} // ${field.def.description}\n`,
686
+ properties: { type: "description", path: field.path },
687
+ });
688
+ } else {
689
+ // Field description (before the field)
690
+ entries.push({
691
+ text: `${indent} // ${field.def.description}\n`,
692
+ properties: { type: "description", path: field.path },
693
+ });
694
+
695
+ // Color field with swatch characters (X for fg preview, space for bg preview)
696
+ const colorStr = formatColorValue(field.value);
697
+
698
+ entries.push({
699
+ text: `${indent} ${field.def.displayName}: X ${colorStr}\n`,
700
+ properties: {
701
+ type: "field",
702
+ path: field.path,
703
+ index: i,
704
+ colorValue: field.value,
705
+ },
706
+ });
707
+ }
708
+
709
+ entries.push({
710
+ text: "\n",
711
+ properties: { type: "blank" },
712
+ });
713
+ }
714
+
715
+ return entries;
716
+ }
717
+
718
+ /**
719
+ * Helper to add a colored overlay (foreground color)
720
+ * addOverlay signature: (bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b, extend_to_line_end)
721
+ */
722
+ function addColorOverlay(
723
+ bufferId: number,
724
+ start: number,
725
+ end: number,
726
+ color: RGB,
727
+ bold: boolean = false
728
+ ): void {
729
+ editor.addOverlay(bufferId, "theme", start, end, color[0], color[1], color[2], false, bold, false, -1, -1, -1, false);
730
+ }
731
+
732
+ /**
733
+ * Helper to add a background highlight overlay
734
+ * addOverlay signature: (bufferId, namespace, start, end, r, g, b, underline, bold, italic, bg_r, bg_g, bg_b, extend_to_line_end)
735
+ */
736
+ function addBackgroundHighlight(
737
+ bufferId: number,
738
+ start: number,
739
+ end: number,
740
+ bgColor: RGB
741
+ ): void {
742
+ editor.addOverlay(bufferId, "theme-selection", start, end, -1, -1, -1, false, false, false, bgColor[0], bgColor[1], bgColor[2], true);
743
+ }
744
+
745
+ /**
746
+ * Check if a field path represents a background color
747
+ */
748
+ function isBackgroundColorField(path: string): boolean {
749
+ // Check if path ends with .bg or contains _bg
750
+ // e.g., "editor.bg", "editor.selection_bg", "ui.tab_active_bg"
751
+ return path.endsWith(".bg") || path.includes("_bg");
752
+ }
753
+
754
+ /**
755
+ * Check if a color is a special color (Default/Reset)
756
+ */
757
+ function isSpecialColor(value: ColorValue): boolean {
758
+ return typeof value === "string" && SPECIAL_COLORS.includes(value);
759
+ }
760
+
761
+ /**
762
+ * Apply syntax highlighting
763
+ */
764
+ function applyHighlighting(): void {
765
+ if (state.bufferId === null) return;
766
+
767
+ const bufferId = state.bufferId;
768
+ editor.clearNamespace(bufferId, "theme");
769
+ editor.clearNamespace(bufferId, "theme-selection");
770
+
771
+ const entries = buildDisplayEntries();
772
+ let byteOffset = 0;
773
+
774
+ // Get current field at cursor to highlight it
775
+ const currentField = getFieldAtCursor();
776
+ const currentFieldPath = currentField?.path;
777
+
778
+ for (const entry of entries) {
779
+ const text = entry.text;
780
+ const textLen = getUtf8ByteLength(text);
781
+ const props = entry.properties as Record<string, unknown>;
782
+ const entryType = props.type as string;
783
+ const entryPath = props.path as string | undefined;
784
+
785
+ // Add selection highlight for current field/section
786
+ if (currentFieldPath && entryPath === currentFieldPath && (entryType === "field" || entryType === "section")) {
787
+ addBackgroundHighlight(bufferId, byteOffset, byteOffset + textLen, colors.selectionBg);
788
+ }
789
+
790
+ if (entryType === "title") {
791
+ addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.sectionHeader, true);
792
+ } else if (entryType === "file-path") {
793
+ addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.description);
794
+ } else if (entryType === "description") {
795
+ addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.description);
796
+ } else if (entryType === "section") {
797
+ addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.sectionHeader, true);
798
+ } else if (entryType === "field") {
799
+ // Field name - light blue
800
+ const colonPos = text.indexOf(":");
801
+ if (colonPos > 0) {
802
+ const nameEnd = byteOffset + getUtf8ByteLength(text.substring(0, colonPos));
803
+ addColorOverlay(bufferId, byteOffset, nameEnd, colors.fieldName);
804
+
805
+ // Color the swatch characters with the field's actual color
806
+ // Text format: "FieldName: X #RRGGBB" (X=fg, space=bg)
807
+ const colorValue = props.colorValue as ColorValue;
808
+ const rgb = parseColorToRgb(colorValue);
809
+ if (rgb) {
810
+ // "X" is at colon + 2 (": " = 2 bytes), and is 1 byte
811
+ const swatchFgStart = nameEnd + getUtf8ByteLength(": ");
812
+ const swatchFgEnd = swatchFgStart + 1; // "X" is 1 byte
813
+ addColorOverlay(bufferId, swatchFgStart, swatchFgEnd, rgb);
814
+
815
+ // First space after "X" is the bg swatch, 1 byte
816
+ const swatchBgStart = swatchFgEnd;
817
+ const swatchBgEnd = swatchBgStart + 1;
818
+ // Use background color for the space
819
+ editor.addOverlay(bufferId, "theme", swatchBgStart, swatchBgEnd, -1, -1, -1, false, false, false, rgb[0], rgb[1], rgb[2], false);
820
+ }
821
+
822
+ // Value (hex code) - custom color (green)
823
+ // Format: ": X #RRGGBB" - value starts after "X " (X + 2 spaces)
824
+ const valueStart = nameEnd + getUtf8ByteLength(": X ");
825
+ addColorOverlay(bufferId, valueStart, byteOffset + textLen, colors.customValue);
826
+ }
827
+ } else if (entryType === "separator" || entryType === "footer") {
828
+ addColorOverlay(bufferId, byteOffset, byteOffset + textLen, colors.footer);
829
+ }
830
+
831
+ byteOffset += textLen;
832
+ }
833
+ }
834
+
835
+ /**
836
+ * Update display (preserves cursor position)
837
+ */
838
+ function updateDisplay(): void {
839
+ if (state.bufferId === null) return;
840
+
841
+ // Save current field path before updating
842
+ const currentPath = getCurrentFieldPath();
843
+
844
+ const entries = buildDisplayEntries();
845
+ editor.setVirtualBufferContent(state.bufferId, entries);
846
+ applyHighlighting();
847
+
848
+ // Restore cursor to the same field if possible
849
+ if (currentPath) {
850
+ moveCursorToField(currentPath);
851
+ }
852
+ }
853
+
854
+ // =============================================================================
855
+ // Field Editing
856
+ // =============================================================================
857
+
858
+ /**
859
+ * Get field at cursor position
860
+ */
861
+ function getFieldAtCursor(): ThemeField | null {
862
+ if (state.bufferId === null) return null;
863
+
864
+ const props = editor.getTextPropertiesAtCursor(state.bufferId);
865
+ if (props.length > 0 && typeof props[0].index === "number") {
866
+ const index = props[0].index as number;
867
+ if (index >= 0 && index < state.visibleFields.length) {
868
+ return state.visibleFields[index];
869
+ }
870
+ }
871
+
872
+ return null;
873
+ }
874
+
875
+ /**
876
+ * Get field by path
877
+ */
878
+ function getFieldByPath(path: string): ThemeField | null {
879
+ return state.visibleFields.find(f => f.path === path) || null;
880
+ }
881
+
882
+ /**
883
+ * Build color suggestions for a field
884
+ */
885
+ function buildColorSuggestions(field: ThemeField): PromptSuggestion[] {
886
+ const currentValue = formatColorValue(field.value);
887
+ const suggestions: PromptSuggestion[] = [
888
+ { text: currentValue, description: editor.t("suggestion.current"), value: currentValue },
889
+ ];
890
+
891
+ // Add special colors (Default/Reset for terminal transparency)
892
+ for (const name of SPECIAL_COLORS) {
893
+ suggestions.push({ text: name, description: editor.t("suggestion.terminal_native"), value: name });
894
+ }
895
+
896
+ // Add named colors with hex format
897
+ for (const name of NAMED_COLOR_LIST) {
898
+ const rgb = NAMED_COLORS[name];
899
+ const hexValue = rgbToHex(rgb[0], rgb[1], rgb[2]);
900
+ suggestions.push({ text: name, description: hexValue, value: name });
901
+ }
902
+
903
+ return suggestions;
904
+ }
905
+
906
+ /**
907
+ * Start color editing prompt
908
+ */
909
+ function editColorField(field: ThemeField): void {
910
+ const currentValue = formatColorValue(field.value);
911
+ editor.startPromptWithInitial(
912
+ editor.t("prompt.color_input", { field: field.def.displayName }),
913
+ `theme-color-${field.path}`,
914
+ currentValue
915
+ );
916
+ editor.setPromptSuggestions(buildColorSuggestions(field));
917
+ }
918
+
919
+ interface ParseColorResult {
920
+ value?: ColorValue;
921
+ error?: string;
922
+ }
923
+
924
+ /**
925
+ * Parse color input from user with detailed error messages
926
+ */
927
+ function parseColorInput(input: string): ParseColorResult {
928
+ input = input.trim();
929
+
930
+ if (!input) {
931
+ return { error: "empty" };
932
+ }
933
+
934
+ // Check for special colors (Default/Reset - use terminal's native color)
935
+ if (SPECIAL_COLORS.includes(input)) {
936
+ return { value: input };
937
+ }
938
+
939
+ // Check for named color
940
+ if (input in NAMED_COLORS) {
941
+ return { value: input };
942
+ }
943
+
944
+ // Try to parse as hex color #RRGGBB
945
+ if (input.startsWith("#")) {
946
+ const hex = input.slice(1);
947
+ if (hex.length !== 6) {
948
+ return { error: "hex_length" };
949
+ }
950
+ if (!/^[0-9A-Fa-f]{6}$/.test(hex)) {
951
+ return { error: "hex_invalid" };
952
+ }
953
+ const hexResult = hexToRgb(input);
954
+ if (hexResult) {
955
+ return { value: hexResult };
956
+ }
957
+ }
958
+
959
+ // Try to parse as RGB array [r, g, b]
960
+ const rgbMatch = input.match(/^\[?\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]?$/);
961
+ if (rgbMatch) {
962
+ const r = parseInt(rgbMatch[1], 10);
963
+ const g = parseInt(rgbMatch[2], 10);
964
+ const b = parseInt(rgbMatch[3], 10);
965
+ if (r > 255 || g > 255 || b > 255) {
966
+ return { error: "rgb_range" };
967
+ }
968
+ return { value: [r, g, b] };
969
+ }
970
+
971
+ // Unknown format
972
+ return { error: "unknown" };
973
+ }
974
+
975
+ // =============================================================================
976
+ // Prompt Handlers
977
+ // =============================================================================
978
+
979
+ /**
980
+ * Find best matching color name for partial input
981
+ */
982
+ function findMatchingColor(input: string): string | null {
983
+ const lower = input.toLowerCase();
984
+ // First try exact match
985
+ for (const name of Object.keys(NAMED_COLORS)) {
986
+ if (name.toLowerCase() === lower) return name;
987
+ }
988
+ for (const name of SPECIAL_COLORS) {
989
+ if (name.toLowerCase() === lower) return name;
990
+ }
991
+ // Then try prefix match
992
+ for (const name of Object.keys(NAMED_COLORS)) {
993
+ if (name.toLowerCase().startsWith(lower)) return name;
994
+ }
995
+ for (const name of SPECIAL_COLORS) {
996
+ if (name.toLowerCase().startsWith(lower)) return name;
997
+ }
998
+ // Then try contains match
999
+ for (const name of Object.keys(NAMED_COLORS)) {
1000
+ if (name.toLowerCase().includes(lower)) return name;
1001
+ }
1002
+ return null;
1003
+ }
1004
+
1005
+ /**
1006
+ * Handle color prompt confirmation
1007
+ */
1008
+ globalThis.onThemeColorPromptConfirmed = function(args: {
1009
+ prompt_type: string;
1010
+ selected_index: number | null;
1011
+ input: string;
1012
+ }): boolean {
1013
+ if (!args.prompt_type.startsWith("theme-color-")) return true;
1014
+
1015
+ const path = args.prompt_type.replace("theme-color-", "");
1016
+ const field = getFieldByPath(path);
1017
+ if (!field) return true;
1018
+
1019
+ const result = parseColorInput(args.input);
1020
+
1021
+ if (result.value !== undefined) {
1022
+ // Valid color - apply it
1023
+ setNestedValue(state.themeData, path, result.value);
1024
+ state.hasChanges = !deepEqual(state.themeData, state.originalThemeData);
1025
+
1026
+ const entries = buildDisplayEntries();
1027
+ if (state.bufferId !== null) {
1028
+ editor.setVirtualBufferContent(state.bufferId, entries);
1029
+ applyHighlighting();
1030
+ }
1031
+ moveCursorToField(path);
1032
+ editor.setStatus(editor.t("status.updated", { path }));
1033
+ } else {
1034
+ // Invalid input - try to find a matching color name
1035
+ const matchedColor = findMatchingColor(args.input);
1036
+ if (matchedColor) {
1037
+ // Found a match - reopen prompt with the matched value
1038
+ editor.startPromptWithInitial(
1039
+ editor.t("prompt.color_input", { field: field.def.displayName }),
1040
+ `theme-color-${path}`,
1041
+ matchedColor
1042
+ );
1043
+ // Rebuild suggestions
1044
+ const suggestions: PromptSuggestion[] = buildColorSuggestions(field);
1045
+ editor.setPromptSuggestions(suggestions);
1046
+ editor.setStatus(editor.t("status.autocompleted", { value: matchedColor }));
1047
+ } else {
1048
+ // No match found - reopen prompt with original input
1049
+ editor.startPromptWithInitial(
1050
+ editor.t("prompt.color_input", { field: field.def.displayName }),
1051
+ `theme-color-${path}`,
1052
+ args.input
1053
+ );
1054
+ const suggestions: PromptSuggestion[] = buildColorSuggestions(field);
1055
+ editor.setPromptSuggestions(suggestions);
1056
+
1057
+ const errorKey = `error.color_${result.error}`;
1058
+ editor.setStatus(editor.t(errorKey, { input: args.input }));
1059
+ }
1060
+ }
1061
+
1062
+ return true;
1063
+ };
1064
+
1065
+ /**
1066
+ * Handle open theme prompt (both builtin and user themes)
1067
+ */
1068
+ globalThis.onThemeOpenPromptConfirmed = async function(args: {
1069
+ prompt_type: string;
1070
+ selected_index: number | null;
1071
+ input: string;
1072
+ }): Promise<boolean> {
1073
+ if (args.prompt_type !== "theme-open") return true;
1074
+
1075
+ const value = args.input.trim();
1076
+
1077
+ // Parse the value to determine if it's user or builtin
1078
+ let isBuiltin = false;
1079
+ let themeName = value;
1080
+
1081
+ if (value.startsWith("user:")) {
1082
+ themeName = value.slice(5);
1083
+ isBuiltin = false;
1084
+ } else if (value.startsWith("builtin:")) {
1085
+ themeName = value.slice(8);
1086
+ isBuiltin = true;
1087
+ } else {
1088
+ // Fallback: check if it's a builtin theme
1089
+ isBuiltin = state.builtinThemes.includes(value);
1090
+ }
1091
+
1092
+ if (isBuiltin) {
1093
+ // Load builtin theme
1094
+ const themeData = await loadThemeFile(themeName);
1095
+ if (themeData) {
1096
+ state.themeData = deepClone(themeData);
1097
+ state.originalThemeData = deepClone(themeData);
1098
+ state.themeName = themeName;
1099
+ state.themePath = null; // No user path for builtin
1100
+ state.isBuiltin = true;
1101
+ state.hasChanges = false;
1102
+ updateDisplay();
1103
+ editor.setStatus(editor.t("status.opened_builtin", { name: themeName }));
1104
+ } else {
1105
+ editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1106
+ }
1107
+ } else {
1108
+ // Load user theme
1109
+ const result = await loadUserThemeFile(themeName);
1110
+ if (result) {
1111
+ state.themeData = deepClone(result.data);
1112
+ state.originalThemeData = deepClone(result.data);
1113
+ state.themeName = themeName;
1114
+ state.themePath = result.path;
1115
+ state.isBuiltin = false;
1116
+ state.hasChanges = false;
1117
+ updateDisplay();
1118
+ editor.setStatus(editor.t("status.loaded", { name: themeName }));
1119
+ } else {
1120
+ editor.setStatus(editor.t("status.load_failed", { name: themeName }));
1121
+ }
1122
+ }
1123
+
1124
+ return true;
1125
+ };
1126
+
1127
+ /**
1128
+ * Handle save as prompt
1129
+ */
1130
+ globalThis.onThemeSaveAsPromptConfirmed = async function(args: {
1131
+ prompt_type: string;
1132
+ selected_index: number | null;
1133
+ input: string;
1134
+ }): Promise<boolean> {
1135
+ if (args.prompt_type !== "theme-save-as") return true;
1136
+
1137
+ const name = args.input.trim();
1138
+ if (name) {
1139
+ // Check if theme already exists
1140
+ const userThemesDir = getUserThemesDir();
1141
+ const targetPath = editor.pathJoin(userThemesDir, `${name}.json`);
1142
+
1143
+ if (editor.fileExists(targetPath)) {
1144
+ // Store pending save name for overwrite confirmation
1145
+ state.pendingSaveName = name;
1146
+ editor.startPrompt(editor.t("prompt.overwrite_confirm", { name }), "theme-overwrite-confirm");
1147
+ const suggestions: PromptSuggestion[] = [
1148
+ { text: editor.t("prompt.overwrite_yes"), description: "", value: "overwrite" },
1149
+ { text: editor.t("prompt.overwrite_no"), description: "", value: "cancel" },
1150
+ ];
1151
+ editor.setPromptSuggestions(suggestions);
1152
+ return true;
1153
+ }
1154
+
1155
+ state.themeName = name;
1156
+ state.themeData.name = name;
1157
+ const restorePath = state.savedCursorPath;
1158
+ state.savedCursorPath = null;
1159
+ await saveTheme(name, restorePath);
1160
+ } else {
1161
+ state.savedCursorPath = null;
1162
+ }
1163
+
1164
+ return true;
1165
+ };
1166
+
1167
+ /**
1168
+ * Handle prompt cancellation
1169
+ */
1170
+ globalThis.onThemePromptCancelled = function(args: { prompt_type: string }): boolean {
1171
+ if (!args.prompt_type.startsWith("theme-")) return true;
1172
+
1173
+ // Clear saved cursor path on cancellation
1174
+ state.savedCursorPath = null;
1175
+ state.pendingSaveName = null;
1176
+
1177
+ editor.setStatus(editor.t("status.cancelled"));
1178
+ return true;
1179
+ };
1180
+
1181
+ /**
1182
+ * Handle initial theme selection prompt (when opening editor)
1183
+ */
1184
+ globalThis.onThemeSelectInitialPromptConfirmed = async function(args: {
1185
+ prompt_type: string;
1186
+ selected_index: number | null;
1187
+ input: string;
1188
+ }): Promise<boolean> {
1189
+ if (args.prompt_type !== "theme-select-initial") return true;
1190
+
1191
+ const value = args.input.trim();
1192
+
1193
+ // Parse the value to determine if it's user or builtin
1194
+ let isBuiltin = false;
1195
+ let themeName = value;
1196
+
1197
+ if (value.startsWith("user:")) {
1198
+ themeName = value.slice(5);
1199
+ isBuiltin = false;
1200
+ } else if (value.startsWith("builtin:")) {
1201
+ themeName = value.slice(8);
1202
+ isBuiltin = true;
1203
+ } else {
1204
+ // Fallback: check if it's a builtin theme
1205
+ isBuiltin = state.builtinThemes.includes(value);
1206
+ }
1207
+
1208
+ editor.setStatus(editor.t("status.loading"));
1209
+
1210
+ if (isBuiltin) {
1211
+ // Load builtin theme
1212
+ const themeData = await loadThemeFile(themeName);
1213
+ if (themeData) {
1214
+ state.themeData = deepClone(themeData);
1215
+ state.originalThemeData = deepClone(themeData);
1216
+ state.themeName = themeName;
1217
+ state.themePath = null; // No user path for builtin
1218
+ state.isBuiltin = true;
1219
+ state.hasChanges = false;
1220
+ } else {
1221
+ // Fallback to default theme if load failed
1222
+ state.themeData = createDefaultTheme();
1223
+ state.originalThemeData = deepClone(state.themeData);
1224
+ state.themeName = themeName;
1225
+ state.themePath = null;
1226
+ state.isBuiltin = true;
1227
+ state.hasChanges = false;
1228
+ }
1229
+ } else {
1230
+ // Load user theme
1231
+ const result = await loadUserThemeFile(themeName);
1232
+ if (result) {
1233
+ state.themeData = deepClone(result.data);
1234
+ state.originalThemeData = deepClone(result.data);
1235
+ state.themeName = themeName;
1236
+ state.themePath = result.path;
1237
+ state.isBuiltin = false;
1238
+ state.hasChanges = false;
1239
+ } else {
1240
+ // Fallback to default theme if load failed
1241
+ state.themeData = createDefaultTheme();
1242
+ state.originalThemeData = deepClone(state.themeData);
1243
+ state.themeName = themeName;
1244
+ state.themePath = null;
1245
+ state.isBuiltin = false;
1246
+ state.hasChanges = false;
1247
+ }
1248
+ }
1249
+
1250
+ // Now open the editor with loaded theme
1251
+ await doOpenThemeEditor();
1252
+
1253
+ return true;
1254
+ };
1255
+
1256
+ // Register prompt handlers
1257
+ editor.on("prompt_confirmed", "onThemeSelectInitialPromptConfirmed");
1258
+ editor.on("prompt_confirmed", "onThemeColorPromptConfirmed");
1259
+ editor.on("prompt_confirmed", "onThemeOpenPromptConfirmed");
1260
+ editor.on("prompt_confirmed", "onThemeSaveAsPromptConfirmed");
1261
+ editor.on("prompt_confirmed", "onThemeDiscardPromptConfirmed");
1262
+ editor.on("prompt_confirmed", "onThemeOverwritePromptConfirmed");
1263
+ editor.on("prompt_confirmed", "onThemeDeletePromptConfirmed");
1264
+ editor.on("prompt_cancelled", "onThemePromptCancelled");
1265
+
1266
+ // =============================================================================
1267
+ // Theme Operations
1268
+ // =============================================================================
1269
+
1270
+ /**
1271
+ * Save theme to file
1272
+ * @param name - Theme name to save as
1273
+ * @param restorePath - Optional field path to restore cursor to after save
1274
+ */
1275
+ async function saveTheme(name?: string, restorePath?: string | null): Promise<boolean> {
1276
+ const themeName = name || state.themeName;
1277
+ const userThemesDir = getUserThemesDir();
1278
+
1279
+ // Ensure themes directory exists
1280
+ if (!editor.fileExists(userThemesDir)) {
1281
+ try {
1282
+ // Create directory via shell command
1283
+ await editor.spawnProcess("mkdir", ["-p", userThemesDir]);
1284
+ } catch (e) {
1285
+ editor.setStatus(editor.t("status.mkdir_failed", { error: String(e) }));
1286
+ return false;
1287
+ }
1288
+ }
1289
+
1290
+ const themePath = editor.pathJoin(userThemesDir, `${themeName}.json`);
1291
+
1292
+ try {
1293
+ state.themeData.name = themeName;
1294
+ const content = JSON.stringify(state.themeData, null, 2);
1295
+ await editor.writeFile(themePath, content);
1296
+
1297
+ state.themePath = themePath;
1298
+ state.themeName = themeName;
1299
+ state.isBuiltin = false; // After saving, it's now a user theme
1300
+ state.originalThemeData = deepClone(state.themeData);
1301
+ state.hasChanges = false;
1302
+
1303
+ // Update display
1304
+ const entries = buildDisplayEntries();
1305
+ if (state.bufferId !== null) {
1306
+ editor.setVirtualBufferContent(state.bufferId, entries);
1307
+ applyHighlighting();
1308
+ }
1309
+
1310
+ // Restore cursor position if provided
1311
+ if (restorePath) {
1312
+ moveCursorToField(restorePath);
1313
+ }
1314
+
1315
+ // Automatically apply the saved theme
1316
+ editor.applyTheme(themeName);
1317
+ editor.setStatus(editor.t("status.saved_and_applied", { name: themeName }));
1318
+ return true;
1319
+ } catch (e) {
1320
+ editor.setStatus(editor.t("status.save_failed", { error: String(e) }));
1321
+ return false;
1322
+ }
1323
+ }
1324
+
1325
+ /**
1326
+ * Create a default/empty theme
1327
+ */
1328
+ function createDefaultTheme(): Record<string, unknown> {
1329
+ return {
1330
+ name: "custom",
1331
+ editor: {
1332
+ bg: [30, 30, 30],
1333
+ fg: [212, 212, 212],
1334
+ cursor: [82, 139, 255],
1335
+ inactive_cursor: [100, 100, 100],
1336
+ selection_bg: [38, 79, 120],
1337
+ current_line_bg: [40, 40, 40],
1338
+ line_number_fg: [100, 100, 100],
1339
+ line_number_bg: [30, 30, 30],
1340
+ },
1341
+ ui: {
1342
+ tab_active_fg: "Yellow",
1343
+ tab_active_bg: "Blue",
1344
+ tab_inactive_fg: "White",
1345
+ tab_inactive_bg: "DarkGray",
1346
+ tab_separator_bg: "Black",
1347
+ status_bar_fg: "White",
1348
+ status_bar_bg: "DarkGray",
1349
+ prompt_fg: "White",
1350
+ prompt_bg: "Black",
1351
+ prompt_selection_fg: "White",
1352
+ prompt_selection_bg: [58, 79, 120],
1353
+ popup_border_fg: "Gray",
1354
+ popup_bg: [30, 30, 30],
1355
+ popup_selection_bg: [58, 79, 120],
1356
+ popup_text_fg: "White",
1357
+ suggestion_bg: [30, 30, 30],
1358
+ suggestion_selected_bg: [58, 79, 120],
1359
+ help_bg: "Black",
1360
+ help_fg: "White",
1361
+ help_key_fg: "Cyan",
1362
+ help_separator_fg: "DarkGray",
1363
+ help_indicator_fg: "Red",
1364
+ help_indicator_bg: "Black",
1365
+ split_separator_fg: [100, 100, 100],
1366
+ terminal_bg: "Default",
1367
+ terminal_fg: "Default",
1368
+ },
1369
+ search: {
1370
+ match_bg: [100, 100, 20],
1371
+ match_fg: [255, 255, 255],
1372
+ },
1373
+ diagnostic: {
1374
+ error_fg: "Red",
1375
+ error_bg: [60, 20, 20],
1376
+ warning_fg: "Yellow",
1377
+ warning_bg: [60, 50, 0],
1378
+ info_fg: "Blue",
1379
+ info_bg: [0, 30, 60],
1380
+ hint_fg: "Gray",
1381
+ hint_bg: [30, 30, 30],
1382
+ },
1383
+ syntax: {
1384
+ keyword: [86, 156, 214],
1385
+ string: [206, 145, 120],
1386
+ comment: [106, 153, 85],
1387
+ function: [220, 220, 170],
1388
+ type: [78, 201, 176],
1389
+ variable: [156, 220, 254],
1390
+ constant: [79, 193, 255],
1391
+ operator: [212, 212, 212],
1392
+ },
1393
+ };
1394
+ }
1395
+
1396
+ // =============================================================================
1397
+ // Cursor Movement Handler
1398
+ // =============================================================================
1399
+
1400
+ globalThis.onThemeEditorCursorMoved = function(data: {
1401
+ buffer_id: number;
1402
+ cursor_id: number;
1403
+ old_position: number;
1404
+ new_position: number;
1405
+ }): void {
1406
+ if (state.bufferId === null || data.buffer_id !== state.bufferId) return;
1407
+
1408
+ applyHighlighting();
1409
+
1410
+ const field = getFieldAtCursor();
1411
+ if (field) {
1412
+ editor.setStatus(field.def.description);
1413
+ }
1414
+ };
1415
+
1416
+ editor.on("cursor_moved", "onThemeEditorCursorMoved");
1417
+
1418
+ /**
1419
+ * Handle buffer_closed event to reset state when buffer is closed by any means
1420
+ */
1421
+ globalThis.onThemeEditorBufferClosed = function(data: {
1422
+ buffer_id: number;
1423
+ }): void {
1424
+ if (state.bufferId !== null && data.buffer_id === state.bufferId) {
1425
+ // Reset state when our buffer is closed
1426
+ state.bufferId = null;
1427
+ state.splitId = null;
1428
+ state.themeData = {};
1429
+ state.originalThemeData = {};
1430
+ state.hasChanges = false;
1431
+ }
1432
+ };
1433
+
1434
+ editor.on("buffer_closed", "onThemeEditorBufferClosed");
1435
+
1436
+ // =============================================================================
1437
+ // Smart Navigation - Skip Non-Selectable Lines
1438
+ // =============================================================================
1439
+
1440
+ interface SelectableEntry {
1441
+ byteOffset: number;
1442
+ valueByteOffset: number; // Position at the value (after "field: ")
1443
+ index: number;
1444
+ isSection: boolean;
1445
+ path: string;
1446
+ }
1447
+
1448
+ /**
1449
+ * Get byte offsets for all selectable entries (fields and sections)
1450
+ */
1451
+ function getSelectableEntries(): SelectableEntry[] {
1452
+ const entries = buildDisplayEntries();
1453
+ const selectableEntries: SelectableEntry[] = [];
1454
+ let byteOffset = 0;
1455
+
1456
+ for (const entry of entries) {
1457
+ const props = entry.properties as Record<string, unknown>;
1458
+ const entryType = props.type as string;
1459
+ const path = (props.path as string) || "";
1460
+
1461
+ // Only fields and sections are selectable (they have index property)
1462
+ if ((entryType === "field" || entryType === "section") && typeof props.index === "number") {
1463
+ // For fields, calculate position at the color value (after "FieldName: X ")
1464
+ let valueByteOffset = byteOffset;
1465
+ if (entryType === "field") {
1466
+ const colonIdx = entry.text.indexOf(":");
1467
+ if (colonIdx >= 0) {
1468
+ // Position at the hex value, after ": X " (colon + space + X + 2 spaces = 5 chars)
1469
+ valueByteOffset = byteOffset + getUtf8ByteLength(entry.text.substring(0, colonIdx + 5));
1470
+ }
1471
+ }
1472
+
1473
+ selectableEntries.push({
1474
+ byteOffset,
1475
+ valueByteOffset,
1476
+ index: props.index as number,
1477
+ isSection: entryType === "section",
1478
+ path,
1479
+ });
1480
+ }
1481
+
1482
+ byteOffset += getUtf8ByteLength(entry.text);
1483
+ }
1484
+
1485
+ return selectableEntries;
1486
+ }
1487
+
1488
+ /**
1489
+ * Get the current selectable entry index based on cursor position
1490
+ */
1491
+ function getCurrentSelectableIndex(): number {
1492
+ if (state.bufferId === null) return -1;
1493
+
1494
+ const props = editor.getTextPropertiesAtCursor(state.bufferId);
1495
+ if (props.length > 0 && typeof props[0].index === "number") {
1496
+ return props[0].index as number;
1497
+ }
1498
+ return -1;
1499
+ }
1500
+
1501
+ /**
1502
+ * Get the current field path at cursor
1503
+ */
1504
+ function getCurrentFieldPath(): string | null {
1505
+ if (state.bufferId === null) return null;
1506
+
1507
+ const props = editor.getTextPropertiesAtCursor(state.bufferId);
1508
+ if (props.length > 0 && typeof props[0].path === "string") {
1509
+ return props[0].path as string;
1510
+ }
1511
+ return null;
1512
+ }
1513
+
1514
+ /**
1515
+ * Move cursor to a field by path (positions at value for fields)
1516
+ */
1517
+ function moveCursorToField(path: string): void {
1518
+ if (state.bufferId === null) return;
1519
+
1520
+ const selectableEntries = getSelectableEntries();
1521
+ for (const entry of selectableEntries) {
1522
+ if (entry.path === path) {
1523
+ // Use valueByteOffset for fields, byteOffset for sections
1524
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1525
+ editor.setBufferCursor(state.bufferId, targetOffset);
1526
+ return;
1527
+ }
1528
+ }
1529
+ }
1530
+
1531
+ /**
1532
+ * Navigate to the next selectable field/section
1533
+ */
1534
+ globalThis.theme_editor_nav_down = function(): void {
1535
+ if (state.bufferId === null) return;
1536
+
1537
+ const selectableEntries = getSelectableEntries();
1538
+ const currentIndex = getCurrentSelectableIndex();
1539
+
1540
+ // Find next selectable entry after current
1541
+ for (const entry of selectableEntries) {
1542
+ if (entry.index > currentIndex) {
1543
+ // Use valueByteOffset for fields, byteOffset for sections
1544
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1545
+ editor.setBufferCursor(state.bufferId, targetOffset);
1546
+ return;
1547
+ }
1548
+ }
1549
+
1550
+ // Already at last selectable, stay there
1551
+ editor.setStatus(editor.t("status.at_last_field"));
1552
+ };
1553
+
1554
+ /**
1555
+ * Navigate to the previous selectable field/section
1556
+ */
1557
+ globalThis.theme_editor_nav_up = function(): void {
1558
+ if (state.bufferId === null) return;
1559
+
1560
+ const selectableEntries = getSelectableEntries();
1561
+ const currentIndex = getCurrentSelectableIndex();
1562
+
1563
+ // Find previous selectable entry before current
1564
+ for (let i = selectableEntries.length - 1; i >= 0; i--) {
1565
+ const entry = selectableEntries[i];
1566
+ if (entry.index < currentIndex) {
1567
+ // Use valueByteOffset for fields, byteOffset for sections
1568
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1569
+ editor.setBufferCursor(state.bufferId, targetOffset);
1570
+ return;
1571
+ }
1572
+ }
1573
+
1574
+ // Already at first selectable, stay there
1575
+ editor.setStatus(editor.t("status.at_first_field"));
1576
+ };
1577
+
1578
+ /**
1579
+ * Navigate to next element (Tab) - includes both fields and sections
1580
+ */
1581
+ globalThis.theme_editor_nav_next_section = function(): void {
1582
+ if (state.bufferId === null) return;
1583
+
1584
+ const selectableEntries = getSelectableEntries();
1585
+ const currentIndex = getCurrentSelectableIndex();
1586
+
1587
+ // Find next selectable entry after current
1588
+ for (const entry of selectableEntries) {
1589
+ if (entry.index > currentIndex) {
1590
+ // Use valueByteOffset for fields, byteOffset for sections
1591
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1592
+ editor.setBufferCursor(state.bufferId, targetOffset);
1593
+ return;
1594
+ }
1595
+ }
1596
+
1597
+ // Wrap to first entry
1598
+ if (selectableEntries.length > 0) {
1599
+ const entry = selectableEntries[0];
1600
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1601
+ editor.setBufferCursor(state.bufferId, targetOffset);
1602
+ }
1603
+ };
1604
+
1605
+ /**
1606
+ * Navigate to previous element (Shift+Tab) - includes both fields and sections
1607
+ */
1608
+ globalThis.theme_editor_nav_prev_section = function(): void {
1609
+ if (state.bufferId === null) return;
1610
+
1611
+ const selectableEntries = getSelectableEntries();
1612
+ const currentIndex = getCurrentSelectableIndex();
1613
+
1614
+ // Find previous selectable entry before current
1615
+ for (let i = selectableEntries.length - 1; i >= 0; i--) {
1616
+ const entry = selectableEntries[i];
1617
+ if (entry.index < currentIndex) {
1618
+ // Use valueByteOffset for fields, byteOffset for sections
1619
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1620
+ editor.setBufferCursor(state.bufferId, targetOffset);
1621
+ return;
1622
+ }
1623
+ }
1624
+
1625
+ // Wrap to last entry
1626
+ if (selectableEntries.length > 0) {
1627
+ const entry = selectableEntries[selectableEntries.length - 1];
1628
+ const targetOffset = entry.isSection ? entry.byteOffset : entry.valueByteOffset;
1629
+ editor.setBufferCursor(state.bufferId, targetOffset);
1630
+ }
1631
+ };
1632
+
1633
+ // =============================================================================
1634
+ // Public Commands
1635
+ // =============================================================================
1636
+
1637
+ /**
1638
+ * Open the theme editor - prompts user to select theme first
1639
+ */
1640
+ globalThis.open_theme_editor = async function(): Promise<void> {
1641
+ if (isThemeEditorOpen()) {
1642
+ // Focus the existing theme editor buffer
1643
+ editor.focusBuffer(state.bufferId!);
1644
+ editor.setStatus(editor.t("status.already_open"));
1645
+ return;
1646
+ }
1647
+
1648
+ // Save context
1649
+ state.sourceSplitId = editor.getActiveSplitId();
1650
+ state.sourceBufferId = editor.getActiveBufferId();
1651
+
1652
+ // Load available themes
1653
+ state.builtinThemes = await loadBuiltinThemes();
1654
+
1655
+ // Get current theme name from config
1656
+ const config = editor.getConfig() as Record<string, unknown>;
1657
+ const currentThemeName = (config?.theme as string) || "dark";
1658
+
1659
+ // Prompt user to select which theme to edit
1660
+ editor.startPrompt(editor.t("prompt.select_theme_to_edit"), "theme-select-initial");
1661
+
1662
+ const suggestions: PromptSuggestion[] = [];
1663
+
1664
+ // Add user themes first
1665
+ const userThemes = listUserThemes();
1666
+ for (const name of userThemes) {
1667
+ const isCurrent = name === currentThemeName;
1668
+ suggestions.push({
1669
+ text: name,
1670
+ description: isCurrent ? editor.t("suggestion.user_theme_current") : editor.t("suggestion.user_theme"),
1671
+ value: `user:${name}`,
1672
+ });
1673
+ }
1674
+
1675
+ // Add built-in themes
1676
+ for (const name of state.builtinThemes) {
1677
+ const isCurrent = name === currentThemeName;
1678
+ suggestions.push({
1679
+ text: name,
1680
+ description: isCurrent ? editor.t("suggestion.builtin_theme_current") : editor.t("suggestion.builtin_theme"),
1681
+ value: `builtin:${name}`,
1682
+ });
1683
+ }
1684
+
1685
+ // Sort suggestions to put current theme first
1686
+ suggestions.sort((a, b) => {
1687
+ const aIsCurrent = a.description.includes("current");
1688
+ const bIsCurrent = b.description.includes("current");
1689
+ if (aIsCurrent && !bIsCurrent) return -1;
1690
+ if (!aIsCurrent && bIsCurrent) return 1;
1691
+ return 0;
1692
+ });
1693
+
1694
+ editor.setPromptSuggestions(suggestions);
1695
+ };
1696
+
1697
+ /**
1698
+ * Actually open the theme editor with loaded theme data
1699
+ */
1700
+ async function doOpenThemeEditor(): Promise<void> {
1701
+ // Build initial entries
1702
+ const entries = buildDisplayEntries();
1703
+
1704
+ // Create virtual buffer in current split (no new split)
1705
+ const bufferId = await editor.createVirtualBuffer({
1706
+ name: "*Theme Editor*",
1707
+ mode: "theme-editor",
1708
+ read_only: true,
1709
+ entries: entries,
1710
+ show_line_numbers: false,
1711
+ show_cursors: true,
1712
+ editing_disabled: true,
1713
+ });
1714
+
1715
+ if (bufferId !== null) {
1716
+ state.bufferId = bufferId;
1717
+ state.splitId = null;
1718
+
1719
+ applyHighlighting();
1720
+ editor.setStatus(editor.t("status.ready"));
1721
+ } else {
1722
+ editor.setStatus(editor.t("status.open_failed"));
1723
+ }
1724
+ }
1725
+
1726
+ /**
1727
+ * Close the theme editor
1728
+ */
1729
+ globalThis.theme_editor_close = function(): void {
1730
+ if (!isThemeEditorOpen()) return;
1731
+
1732
+ if (state.hasChanges) {
1733
+ // Show confirmation prompt before closing with unsaved changes
1734
+ editor.startPrompt(editor.t("prompt.discard_confirm"), "theme-discard-confirm");
1735
+ const suggestions: PromptSuggestion[] = [
1736
+ { text: editor.t("prompt.discard_yes"), description: "", value: "discard" },
1737
+ { text: editor.t("prompt.discard_no"), description: "", value: "keep" },
1738
+ ];
1739
+ editor.setPromptSuggestions(suggestions);
1740
+ return;
1741
+ }
1742
+
1743
+ doCloseEditor();
1744
+ };
1745
+
1746
+ /**
1747
+ * Actually close the editor (called after confirmation or when no changes)
1748
+ */
1749
+ function doCloseEditor(): void {
1750
+ // Close the buffer (this will switch to another buffer in the same split)
1751
+ if (state.bufferId !== null) {
1752
+ editor.closeBuffer(state.bufferId);
1753
+ }
1754
+
1755
+ // Reset state
1756
+ state.bufferId = null;
1757
+ state.splitId = null;
1758
+ state.themeData = {};
1759
+ state.originalThemeData = {};
1760
+ state.hasChanges = false;
1761
+
1762
+ editor.setStatus(editor.t("status.closed"));
1763
+ }
1764
+
1765
+ /**
1766
+ * Handle discard confirmation prompt
1767
+ */
1768
+ globalThis.onThemeDiscardPromptConfirmed = function(args: {
1769
+ prompt_type: string;
1770
+ selected_index: number | null;
1771
+ input: string;
1772
+ }): boolean {
1773
+ if (args.prompt_type !== "theme-discard-confirm") return true;
1774
+
1775
+ const response = args.input.trim().toLowerCase();
1776
+ if (response === "discard" || args.selected_index === 0) {
1777
+ editor.setStatus(editor.t("status.unsaved_discarded"));
1778
+ doCloseEditor();
1779
+ } else {
1780
+ editor.setStatus(editor.t("status.cancelled"));
1781
+ }
1782
+
1783
+ return false;
1784
+ };
1785
+
1786
+ /**
1787
+ * Edit color at cursor
1788
+ */
1789
+ globalThis.theme_editor_edit_color = function(): void {
1790
+ const field = getFieldAtCursor();
1791
+ if (!field) {
1792
+ editor.setStatus(editor.t("status.no_field"));
1793
+ return;
1794
+ }
1795
+
1796
+ if (field.isSection) {
1797
+ theme_editor_toggle_section();
1798
+ return;
1799
+ }
1800
+
1801
+ editColorField(field);
1802
+ };
1803
+
1804
+ /**
1805
+ * Toggle section expansion
1806
+ */
1807
+ globalThis.theme_editor_toggle_section = function(): void {
1808
+ const field = getFieldAtCursor();
1809
+ if (!field || !field.isSection) {
1810
+ editor.setStatus(editor.t("status.not_section"));
1811
+ return;
1812
+ }
1813
+
1814
+ if (state.expandedSections.has(field.path)) {
1815
+ state.expandedSections.delete(field.path);
1816
+ } else {
1817
+ state.expandedSections.add(field.path);
1818
+ }
1819
+
1820
+ updateDisplay();
1821
+ };
1822
+
1823
+ /**
1824
+ * Open a theme (builtin or user) for editing
1825
+ */
1826
+ globalThis.theme_editor_open = function(): void {
1827
+ editor.startPrompt(editor.t("prompt.open_theme"), "theme-open");
1828
+
1829
+ const suggestions: PromptSuggestion[] = [];
1830
+
1831
+ // Add user themes first
1832
+ const userThemes = listUserThemes();
1833
+ for (const name of userThemes) {
1834
+ suggestions.push({
1835
+ text: name,
1836
+ description: editor.t("suggestion.user_theme"),
1837
+ value: `user:${name}`,
1838
+ });
1839
+ }
1840
+
1841
+ // Add built-in themes
1842
+ for (const name of state.builtinThemes) {
1843
+ suggestions.push({
1844
+ text: name,
1845
+ description: editor.t("suggestion.builtin_theme"),
1846
+ value: `builtin:${name}`,
1847
+ });
1848
+ }
1849
+
1850
+ editor.setPromptSuggestions(suggestions);
1851
+ };
1852
+
1853
+ /**
1854
+ * Save theme
1855
+ */
1856
+ globalThis.theme_editor_save = async function(): Promise<void> {
1857
+ // Save cursor path for restoration after save
1858
+ state.savedCursorPath = getCurrentFieldPath();
1859
+
1860
+ // Built-in themes require Save As
1861
+ if (state.isBuiltin) {
1862
+ editor.setStatus(editor.t("status.builtin_requires_save_as"));
1863
+ theme_editor_save_as();
1864
+ return;
1865
+ }
1866
+
1867
+ // If theme has never been saved (no path), trigger "Save As" instead
1868
+ if (!state.themePath) {
1869
+ theme_editor_save_as();
1870
+ return;
1871
+ }
1872
+
1873
+ if (!state.hasChanges) {
1874
+ editor.setStatus(editor.t("status.no_changes"));
1875
+ return;
1876
+ }
1877
+
1878
+ // Check for name collision if name has changed since last save
1879
+ const userThemesDir = getUserThemesDir();
1880
+ const targetPath = editor.pathJoin(userThemesDir, `${state.themeName}.json`);
1881
+
1882
+ if (state.themePath !== targetPath && editor.fileExists(targetPath)) {
1883
+ // File exists with this name - ask for confirmation
1884
+ editor.startPrompt(editor.t("prompt.overwrite_confirm", { name: state.themeName }), "theme-overwrite-confirm");
1885
+ const suggestions: PromptSuggestion[] = [
1886
+ { text: editor.t("prompt.overwrite_yes"), description: "", value: "overwrite" },
1887
+ { text: editor.t("prompt.overwrite_no"), description: "", value: "cancel" },
1888
+ ];
1889
+ editor.setPromptSuggestions(suggestions);
1890
+ return;
1891
+ }
1892
+
1893
+ await saveTheme(undefined, state.savedCursorPath);
1894
+ };
1895
+
1896
+ /**
1897
+ * Handle overwrite confirmation prompt
1898
+ */
1899
+ globalThis.onThemeOverwritePromptConfirmed = async function(args: {
1900
+ prompt_type: string;
1901
+ selected_index: number | null;
1902
+ input: string;
1903
+ }): Promise<boolean> {
1904
+ if (args.prompt_type !== "theme-overwrite-confirm") return true;
1905
+
1906
+ const response = args.input.trim().toLowerCase();
1907
+ if (response === "overwrite" || args.selected_index === 0) {
1908
+ // Use pending name if set (from Save As), otherwise use current name
1909
+ const nameToSave = state.pendingSaveName || state.themeName;
1910
+ state.themeName = nameToSave;
1911
+ state.themeData.name = nameToSave;
1912
+ state.pendingSaveName = null;
1913
+ const restorePath = state.savedCursorPath;
1914
+ state.savedCursorPath = null;
1915
+ await saveTheme(nameToSave, restorePath);
1916
+ } else {
1917
+ state.pendingSaveName = null;
1918
+ state.savedCursorPath = null;
1919
+ editor.setStatus(editor.t("status.cancelled"));
1920
+ }
1921
+
1922
+ return false;
1923
+ };
1924
+
1925
+ /**
1926
+ * Save theme as (new name)
1927
+ */
1928
+ globalThis.theme_editor_save_as = function(): void {
1929
+ // Save cursor path for restoration after save (if not already saved by theme_editor_save)
1930
+ if (!state.savedCursorPath) {
1931
+ state.savedCursorPath = getCurrentFieldPath();
1932
+ }
1933
+
1934
+ editor.startPrompt(editor.t("prompt.save_as"), "theme-save-as");
1935
+
1936
+ editor.setPromptSuggestions([{
1937
+ text: state.themeName,
1938
+ description: editor.t("suggestion.current"),
1939
+ value: state.themeName,
1940
+ }]);
1941
+ };
1942
+
1943
+ /**
1944
+ * Reload theme
1945
+ */
1946
+ globalThis.theme_editor_reload = async function(): Promise<void> {
1947
+ if (state.themePath) {
1948
+ const themeName = state.themeName;
1949
+ const themeData = await loadThemeFile(themeName);
1950
+ if (themeData) {
1951
+ state.themeData = deepClone(themeData);
1952
+ state.originalThemeData = deepClone(themeData);
1953
+ state.hasChanges = false;
1954
+ updateDisplay();
1955
+ editor.setStatus(editor.t("status.reloaded"));
1956
+ }
1957
+ } else {
1958
+ state.themeData = createDefaultTheme();
1959
+ state.originalThemeData = deepClone(state.themeData);
1960
+ state.hasChanges = false;
1961
+ updateDisplay();
1962
+ editor.setStatus(editor.t("status.reset"));
1963
+ }
1964
+ };
1965
+
1966
+ /**
1967
+ * Show help
1968
+ */
1969
+ globalThis.theme_editor_show_help = function(): void {
1970
+ editor.setStatus(editor.t("status.help"));
1971
+ };
1972
+
1973
+ /**
1974
+ * Delete the current user theme
1975
+ */
1976
+ globalThis.theme_editor_delete = function(): void {
1977
+ // Can only delete saved user themes
1978
+ if (!state.themePath) {
1979
+ editor.setStatus(editor.t("status.cannot_delete_unsaved"));
1980
+ return;
1981
+ }
1982
+
1983
+ // Show confirmation dialog
1984
+ editor.startPrompt(editor.t("prompt.delete_confirm", { name: state.themeName }), "theme-delete-confirm");
1985
+ const suggestions: PromptSuggestion[] = [
1986
+ { text: editor.t("prompt.delete_yes"), description: "", value: "delete" },
1987
+ { text: editor.t("prompt.delete_no"), description: "", value: "cancel" },
1988
+ ];
1989
+ editor.setPromptSuggestions(suggestions);
1990
+ };
1991
+
1992
+ /**
1993
+ * Handle delete confirmation prompt
1994
+ */
1995
+ globalThis.onThemeDeletePromptConfirmed = async function(args: {
1996
+ prompt_type: string;
1997
+ selected_index: number | null;
1998
+ input: string;
1999
+ }): Promise<boolean> {
2000
+ if (args.prompt_type !== "theme-delete-confirm") return true;
2001
+
2002
+ const value = args.input.trim();
2003
+ if (value === "delete" || value === editor.t("prompt.delete_yes")) {
2004
+ if (state.themeName) {
2005
+ try {
2006
+ // Delete the theme file by name
2007
+ await editor.deleteTheme(state.themeName);
2008
+ const deletedName = state.themeName;
2009
+
2010
+ // Reset to default theme
2011
+ state.themeData = createDefaultTheme();
2012
+ state.originalThemeData = deepClone(state.themeData);
2013
+ state.themeName = "custom";
2014
+ state.themePath = null;
2015
+ state.hasChanges = false;
2016
+ updateDisplay();
2017
+
2018
+ editor.setStatus(editor.t("status.deleted", { name: deletedName }));
2019
+ } catch (e) {
2020
+ editor.setStatus(editor.t("status.delete_failed", { error: String(e) }));
2021
+ }
2022
+ }
2023
+ } else {
2024
+ editor.setStatus(editor.t("status.cancelled"));
2025
+ }
2026
+
2027
+ return true;
2028
+ };
2029
+
2030
+ // =============================================================================
2031
+ // Command Registration
2032
+ // =============================================================================
2033
+
2034
+ // Main command to open theme editor (always available)
2035
+ editor.registerCommand(
2036
+ "%cmd.edit_theme",
2037
+ "%cmd.edit_theme_desc",
2038
+ "open_theme_editor",
2039
+ "normal"
2040
+ );
2041
+
2042
+ // Buffer-scoped commands - only visible when a buffer with mode "theme-editor" is focused
2043
+ // The core automatically checks the focused buffer's mode against command contexts
2044
+ editor.registerCommand("%cmd.close_editor", "%cmd.close_editor_desc", "theme_editor_close", "theme-editor");
2045
+ editor.registerCommand("%cmd.edit_color", "%cmd.edit_color_desc", "theme_editor_edit_color", "theme-editor");
2046
+ editor.registerCommand("%cmd.toggle_section", "%cmd.toggle_section_desc", "theme_editor_toggle_section", "theme-editor");
2047
+ editor.registerCommand("%cmd.open_theme", "%cmd.open_theme_desc", "theme_editor_open", "theme-editor");
2048
+ editor.registerCommand("%cmd.save", "%cmd.save_desc", "theme_editor_save", "theme-editor");
2049
+ editor.registerCommand("%cmd.save_as", "%cmd.save_as_desc", "theme_editor_save_as", "theme-editor");
2050
+ editor.registerCommand("%cmd.reload", "%cmd.reload_desc", "theme_editor_reload", "theme-editor");
2051
+ editor.registerCommand("%cmd.show_help", "%cmd.show_help_desc", "theme_editor_show_help", "theme-editor");
2052
+ editor.registerCommand("%cmd.delete_theme", "%cmd.delete_theme_desc", "theme_editor_delete", "theme-editor");
2053
+ editor.registerCommand("%cmd.nav_up", "%cmd.nav_up_desc", "theme_editor_nav_up", "theme-editor");
2054
+ editor.registerCommand("%cmd.nav_down", "%cmd.nav_down_desc", "theme_editor_nav_down", "theme-editor");
2055
+ editor.registerCommand("%cmd.nav_next", "%cmd.nav_next_desc", "theme_editor_nav_next_section", "theme-editor");
2056
+ editor.registerCommand("%cmd.nav_prev", "%cmd.nav_prev_desc", "theme_editor_nav_prev_section", "theme-editor");
2057
+
2058
+ // =============================================================================
2059
+ // Plugin Initialization
2060
+ // =============================================================================
2061
+
2062
+ editor.setStatus(editor.t("status.plugin_loaded"));
2063
+ editor.debug("Theme Editor plugin initialized - Use 'Edit Theme' command to open");