@underactive/pi-topping 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -1,63 +1,134 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
4
5
  import type { MenuSection } from "./menu.ts";
5
6
 
7
+ export const SETTING_COLOR_VALUES = ["accent", "border", "borderAccent", "success", "error", "warning"] as const;
8
+ export type SettingColor = (typeof SETTING_COLOR_VALUES)[number];
9
+
10
+ export const BORDER_STYLE_VALUES = ["double", "single", "rounded", "heavy"] as const;
11
+ export type BorderStyle = (typeof BORDER_STYLE_VALUES)[number];
12
+ export const DONE_MARKER_BORDER_STYLE_VALUES = [...BORDER_STYLE_VALUES, "none"] as const;
13
+ export type DoneMarkerBorderStyle = (typeof DONE_MARKER_BORDER_STYLE_VALUES)[number];
14
+
15
+ export function isBorderStyle(value: unknown): value is BorderStyle {
16
+ return typeof value === "string" && BORDER_STYLE_VALUES.some(style => style === value);
17
+ }
18
+
19
+ export function isDoneMarkerBorderStyle(value: unknown): value is DoneMarkerBorderStyle {
20
+ return typeof value === "string" && DONE_MARKER_BORDER_STYLE_VALUES.some(style => style === value);
21
+ }
22
+
23
+ export function isSettingColor(value: unknown): value is SettingColor {
24
+ return typeof value === "string" && SETTING_COLOR_VALUES.some(color => color === value);
25
+ }
26
+
6
27
  export interface DecoratorSettings {
7
28
  decorations: {
8
29
  animatedSpinner: boolean;
9
30
  shimmer: boolean;
31
+ shimmerInverted: boolean;
10
32
  shimmerDirection: "ltr" | "rtl";
11
33
  shimmerDirectionEnabled: boolean;
34
+ shimmerSpeed: "slow" | "normal" | "fast";
35
+ shimmerSpeedEnabled: boolean;
12
36
  tokenActivityMonitor: boolean;
13
37
  meterDirection: "ltr" | "rtl";
14
38
  meterDirectionEnabled: boolean;
15
39
  decorateUserPrompt: boolean;
16
- borderColor: "accent" | "border" | "borderAccent";
40
+ borderColor: SettingColor;
17
41
  borderColorEnabled: boolean;
18
- spinnerColor: "accent" | "border" | "borderAccent";
42
+ borderStyle: BorderStyle;
43
+ borderStyleEnabled: boolean;
44
+ doneMarkerBorderStyle: DoneMarkerBorderStyle;
45
+ doneMarkerBorderColor: SettingColor;
46
+ spinnerColor: SettingColor;
19
47
  spinnerColorEnabled: boolean;
20
- meterColor: "accent" | "border" | "borderAccent";
48
+ meterColor: SettingColor;
21
49
  meterColorEnabled: boolean;
22
50
  meterDimmed: boolean;
51
+ tokenRateColor: SettingColor;
52
+ tokenRateDimmed: boolean;
23
53
  promptIcon: boolean;
24
54
  promptTimestamp: boolean;
55
+ promptProvider: boolean;
56
+ promptModel: boolean;
25
57
  useNerdFont: boolean;
26
58
  };
27
59
  features: {
28
60
  substituteDefaultMessage: boolean;
29
61
  elapsedTime: boolean;
30
62
  outputTokens: boolean;
63
+ tokenRate: boolean;
31
64
  doneMarker: boolean;
32
65
  doneMarkerIcon: boolean;
33
66
  randomizeDoneMarker: boolean;
34
67
  doneMarkerTokens: boolean;
68
+ doneMarkerInputs: boolean;
35
69
  };
70
+ loaderOrder: LoaderElement[];
36
71
  }
37
72
 
38
73
  export const DEFAULT_SETTINGS: DecoratorSettings = {
39
- decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, tokenActivityMonitor: true, meterDirection: "ltr", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "accent", borderColorEnabled: true, spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, promptIcon: true, promptTimestamp: true, useNerdFont: true },
40
- features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true },
74
+ decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
75
+ features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
76
+ loaderOrder: [...DEFAULT_LOADER_ORDER],
77
+ };
78
+
79
+ /** Menu key carrying the loader element order as a comma-joined list of element ids. */
80
+ export const LOADER_ORDER_ID = "loaderOrder";
81
+ const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
82
+ spinner: "Animated spinner",
83
+ text: "“Working” text",
84
+ meter: "Token activity monitor",
85
+ elapsed: "Elapsed time",
86
+ tokens: "Output tokens",
87
+ tokenRate: "Token rate",
41
88
  };
42
89
 
90
+ /**
91
+ * Normalize an element order from disk or from the menu, tolerating hand edits and
92
+ * version skew: unknown entries and duplicates are dropped, missing elements are
93
+ * appended in their default positions.
94
+ */
95
+ export function parseLoaderOrder(value: unknown): LoaderElement[] {
96
+ const entries = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
97
+ const order: LoaderElement[] = [];
98
+ for (const entry of entries) {
99
+ const element = typeof entry === "string" ? entry.trim() as LoaderElement : undefined;
100
+ if (element && DEFAULT_LOADER_ORDER.includes(element) && !order.includes(element)) order.push(element);
101
+ }
102
+ for (const element of DEFAULT_LOADER_ORDER) if (!order.includes(element)) order.push(element);
103
+ return order;
104
+ }
105
+
43
106
  export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
44
107
  function isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
45
108
  function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, parsed: unknown): T {
46
109
  const merged = { ...defaults };
47
110
  if (!isPlainObject(parsed)) return merged;
48
111
  for (const [key, value] of Object.entries(parsed)) {
49
- if (!(key in merged)) continue;
50
- if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
51
- if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) (merged as Record<string, boolean | string>)[key] = value;
52
- if (key.endsWith("Color") && (value === "accent" || value === "border" || value === "borderAccent")) (merged as Record<string, boolean | string>)[key] = value;
112
+ if (!Object.hasOwn(merged, key)) continue;
113
+ let valid: boolean | string | undefined;
114
+ if (typeof merged[key] === "boolean" && typeof value === "boolean") valid = value;
115
+ else if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) valid = value;
116
+ else if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) valid = value;
117
+ else if (key.endsWith("Color") && isSettingColor(value)) valid = value;
118
+ else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
119
+ else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
120
+ if (valid !== undefined) (merged as Record<string, boolean | string>)[key] = valid;
53
121
  }
54
122
  return merged;
55
123
  }
56
124
 
57
- /** Map a persisted setting value to its menu cycle label (directions only; colors pass through). */
125
+ /** Map a persisted setting value to its menu cycle label (directions and speeds only; colors pass through). */
58
126
  function toCycleValue(stored: unknown): string {
59
127
  if (stored === "ltr") return "Left to Right";
60
128
  if (stored === "rtl") return "Right to Left";
129
+ if (stored === "slow") return "Slow";
130
+ if (stored === "normal") return "Normal";
131
+ if (stored === "fast") return "Fast";
61
132
  return String(stored);
62
133
  }
63
134
 
@@ -65,10 +136,25 @@ function toCycleValue(stored: unknown): string {
65
136
  function fromCycleValue(value: string): string {
66
137
  if (value === "Left to Right") return "ltr";
67
138
  if (value === "Right to Left") return "rtl";
139
+ if (value === "Slow") return "slow";
140
+ if (value === "Normal") return "normal";
141
+ if (value === "Fast") return "fast";
68
142
  return value;
69
143
  }
70
144
 
71
- type MenuSectionName = "User Prompt" | "Working Loader Text" | "Completion Marker" | "Options";
145
+ /** Convert a persisted or menu direction to a safe preview direction. */
146
+ export function fromCycleDirection(value: unknown): "ltr" | "rtl" {
147
+ const stored = typeof value === "string" ? fromCycleValue(value) : "";
148
+ return stored === "rtl" ? "rtl" : "ltr";
149
+ }
150
+
151
+ /** Convert a persisted or menu speed to a safe preview speed. */
152
+ export function fromCycleSpeed(value: unknown): "slow" | "normal" | "fast" {
153
+ const stored = typeof value === "string" ? fromCycleValue(value) : "";
154
+ return stored === "slow" || stored === "fast" ? stored : "normal";
155
+ }
156
+
157
+ type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
72
158
  type DecorationSettings = DecoratorSettings["decorations"];
73
159
  type FeatureSettings = DecoratorSettings["features"];
74
160
  type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
@@ -78,24 +164,36 @@ type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureS
78
164
  type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
79
165
  export const MENU_ENTRIES: readonly MenuEntry[] = [
80
166
  { id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" },
81
- { id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "border" },
167
+ { id: "borderStyle", label: "Border style", section: "User Prompt", group: "decorations", key: "borderStyle", cycleValues: BORDER_STYLE_VALUES, cycleEnabledBy: "borderStyleEnabled", cycleDisabledValue: "double" },
168
+ { id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "borderAccent" },
82
169
  { id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
83
170
  { id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" },
84
- { id: "animatedSpinner", label: "Animated spinner", section: "Working Loader Text", group: "decorations", key: "animatedSpinner" },
85
- { id: "spinnerColor", label: "Animated spinner color", section: "Working Loader Text", group: "decorations", key: "spinnerColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
86
- { id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "Working Loader Text", group: "features", key: "substituteDefaultMessage" },
87
- { id: "shimmer", label: "Text shimmer", section: "Working Loader Text", group: "decorations", key: "shimmer" },
88
- { id: "shimmerDirection", label: "Text shimmer direction", section: "Working Loader Text", group: "decorations", key: "shimmerDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "Left to Right" },
89
- { id: "tokenActivityMonitor", label: "Token activity monitor", section: "Working Loader Text", group: "decorations", key: "tokenActivityMonitor" },
90
- { id: "meterColor", label: "Token activity monitor color", section: "Working Loader Text", group: "decorations", key: "meterColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
91
- { id: "meterDirection", label: "Token activity monitor direction", section: "Working Loader Text", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Left to Right" },
92
- { id: "meterDimmed", label: "Token activity monitor dimmed", section: "Working Loader Text", group: "decorations", key: "meterDimmed" },
93
- { id: "elapsedTime", label: "Elapsed time since prompt", section: "Working Loader Text", group: "features", key: "elapsedTime" },
94
- { id: "outputTokens", label: "Show output tokens", section: "Working Loader Text", group: "features", key: "outputTokens" },
171
+ { id: "promptProvider", label: "Provider", section: "User Prompt", group: "decorations", key: "promptProvider" },
172
+ { id: "promptModel", label: "Model", section: "User Prompt", group: "decorations", key: "promptModel" },
173
+ { id: "animatedSpinner", label: "Animated spinner", section: "“Working” Loader", group: "decorations", key: "animatedSpinner" },
174
+ { id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
175
+ { id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "“Working” Loader", group: "features", key: "substituteDefaultMessage" },
176
+ { id: "shimmer", label: "Text shimmer", section: "“Working” Loader", group: "decorations", key: "shimmer" },
177
+ { id: "shimmerInverted", label: "Invert shimmer", section: "“Working” Loader", group: "decorations", key: "shimmerInverted" },
178
+ { id: "shimmerDirection", label: "Text shimmer direction", section: "“Working” Loader", group: "decorations", key: "shimmerDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "Left to Right" },
179
+ { id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["Slow", "Normal", "Fast"], cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "Normal" },
180
+ { id: "tokenActivityMonitor", label: "Token activity monitor", section: "“Working” Loader", group: "decorations", key: "tokenActivityMonitor" },
181
+ { id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
182
+ { id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Right to Left" },
183
+ { id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
184
+ { id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
185
+ { id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" },
186
+ // id differs from key: the Elements Order row already owns "tokenRate" in the menu's shared value namespace.
187
+ { id: "showTokenRate", label: "Token rate", section: "“Working” Loader", group: "features", key: "tokenRate" },
188
+ { id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: SETTING_COLOR_VALUES },
189
+ { id: "tokenRateDimmed", label: "Token rate dimmed", section: "“Working” Loader", group: "decorations", key: "tokenRateDimmed" },
95
190
  { id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
191
+ { id: "doneMarkerBorderStyle", label: "Border style", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderStyle", cycleValues: DONE_MARKER_BORDER_STYLE_VALUES },
192
+ { id: "doneMarkerBorderColor", label: "Border color", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderColor", cycleValues: SETTING_COLOR_VALUES },
96
193
  { id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
97
194
  { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
98
195
  { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
196
+ { id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
99
197
  { id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
100
198
  ];
101
199
 
@@ -117,20 +215,41 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
117
215
  case "borderColor":
118
216
  case "spinnerColor":
119
217
  case "meterColor":
120
- if (stored === "accent" || stored === "border" || stored === "borderAccent") decorations[key] = stored;
218
+ case "tokenRateColor":
219
+ case "doneMarkerBorderColor":
220
+ if (isSettingColor(stored)) decorations[key] = stored;
221
+ return;
222
+ case "borderStyle":
223
+ if (isBorderStyle(stored)) decorations[key] = stored;
224
+ return;
225
+ case "doneMarkerBorderStyle":
226
+ if (isDoneMarkerBorderStyle(stored)) decorations[key] = stored;
121
227
  return;
122
228
  case "shimmerDirection":
123
229
  case "meterDirection":
124
230
  if (stored === "ltr" || stored === "rtl") decorations[key] = stored;
125
231
  return;
232
+ case "shimmerSpeed":
233
+ if (stored === "slow" || stored === "normal" || stored === "fast") decorations[key] = stored;
234
+ return;
126
235
  default:
127
236
  // Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here.
128
237
  throw new Error(`Unhandled cycle setting: ${String(key)}`);
129
238
  }
130
239
  }
131
240
 
241
+ function buildSection(title: MenuSectionName, settings: DecoratorSettings): MenuSection {
242
+ return { title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) };
243
+ }
244
+
132
245
  export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
133
- return ["User Prompt", "Working Loader Text", "Completion Marker", "Options"].map(title => ({ title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) }));
246
+ return [
247
+ buildSection("User Prompt", settings),
248
+ buildSection("“Working” Loader", settings),
249
+ { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
250
+ buildSection("Completion Marker", settings),
251
+ buildSection("Options", settings),
252
+ ];
134
253
  }
135
254
 
136
255
  export function applyMenuResult(settings: DecoratorSettings, values: Record<string, boolean | string>): DecoratorSettings {
@@ -147,28 +266,39 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
147
266
  next.features[entry.key] = value;
148
267
  }
149
268
  }
269
+ if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]);
150
270
  return next;
151
271
  }
152
272
 
273
+ /**
274
+ * Read settings.json, merging valid leaves over the defaults and falling back
275
+ * to defaults on any read/parse error. When a cycle setting's enabled flag is
276
+ * off, its stored value resets to the menu's disabled default so stale choices
277
+ * don't resurface on re-enable.
278
+ */
153
279
  export function loadSettings(): DecoratorSettings {
154
280
  try {
155
281
  const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
156
282
  if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
157
- const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features) };
158
- const gated: readonly (readonly ["borderColor" | "spinnerColor" | "meterColor" | "shimmerDirection" | "meterDirection", "borderColorEnabled" | "spinnerColorEnabled" | "meterColorEnabled" | "shimmerDirectionEnabled" | "meterDirectionEnabled", string])[] = [
159
- ["borderColor", "borderColorEnabled", "border"],
160
- ["spinnerColor", "spinnerColorEnabled", "accent"],
161
- ["meterColor", "meterColorEnabled", "accent"],
162
- ["shimmerDirection", "shimmerDirectionEnabled", "ltr"],
163
- ["meterDirection", "meterDirectionEnabled", "ltr"],
164
- ];
165
- for (const [key, enabledKey, resetValue] of gated) {
166
- if (!settings.decorations[enabledKey]) (settings.decorations as unknown as Record<string, string>)[key] = resetValue;
283
+ const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder) };
284
+ for (const entry of MENU_ENTRIES) {
285
+ if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
286
+ setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
287
+ }
167
288
  }
168
289
  return settings;
169
290
  } catch { return structuredClone(DEFAULT_SETTINGS); }
170
291
  }
292
+
171
293
  export function saveSettings(settings: DecoratorSettings): void {
172
- const path = settingsPath(); mkdirSync(dirname(path), { recursive: true });
173
- writeFileSync(`${path}.tmp`, `${JSON.stringify(settings, null, 2)}\n`); renameSync(`${path}.tmp`, path);
294
+ const path = settingsPath();
295
+ mkdirSync(dirname(path), { recursive: true });
296
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
297
+ try {
298
+ writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { flag: "wx" });
299
+ renameSync(tmpPath, path);
300
+ } catch (err) {
301
+ try { unlinkSync(tmpPath); } catch { /* tmp file was never created */ }
302
+ throw err;
303
+ }
174
304
  }