@underactive/pi-topping 0.2.1 → 0.3.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,12 +1,3 @@
1
- /**
2
- * settings.ts
3
- *
4
- * Persistent, per-user settings for pi-topping, stored at
5
- * `~/.pi/agent/pi-topping/settings.json`. All seven toggles
6
- * default to enabled so behavior is unchanged for users who never open
7
- * `/topping-settings`.
8
- */
9
-
10
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
3
  import { dirname, join } from "node:path";
@@ -16,200 +7,168 @@ export interface DecoratorSettings {
16
7
  decorations: {
17
8
  animatedSpinner: boolean;
18
9
  shimmer: boolean;
10
+ shimmerDirection: "ltr" | "rtl";
11
+ shimmerDirectionEnabled: boolean;
19
12
  tokenActivityMonitor: boolean;
20
13
  meterDirection: "ltr" | "rtl";
14
+ meterDirectionEnabled: boolean;
15
+ decorateUserPrompt: boolean;
16
+ borderColor: "accent" | "border" | "borderAccent";
17
+ borderColorEnabled: boolean;
18
+ spinnerColor: "accent" | "border" | "borderAccent";
19
+ spinnerColorEnabled: boolean;
20
+ meterColor: "accent" | "border" | "borderAccent";
21
+ meterColorEnabled: boolean;
22
+ meterDimmed: boolean;
23
+ promptIcon: boolean;
24
+ promptTimestamp: boolean;
25
+ useNerdFont: boolean;
21
26
  };
22
27
  features: {
23
28
  substituteDefaultMessage: boolean;
24
29
  elapsedTime: boolean;
25
30
  outputTokens: boolean;
26
31
  doneMarker: boolean;
32
+ doneMarkerIcon: boolean;
33
+ randomizeDoneMarker: boolean;
34
+ doneMarkerTokens: boolean;
27
35
  };
28
36
  }
29
37
 
30
38
  export const DEFAULT_SETTINGS: DecoratorSettings = {
31
- decorations: {
32
- animatedSpinner: true,
33
- shimmer: true,
34
- tokenActivityMonitor: true,
35
- meterDirection: "ltr",
36
- },
37
- features: {
38
- substituteDefaultMessage: true,
39
- elapsedTime: true,
40
- outputTokens: true,
41
- doneMarker: true,
42
- },
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 },
43
41
  };
44
42
 
45
- /** Resolve the absolute path to this extension's settings file. */
46
- export function settingsPath(): string {
47
- return join(getAgentDir(), "pi-topping", "settings.json");
48
- }
49
-
50
- /**
51
- * True for plain `{}`-style objects only -- excludes `null`, arrays, and
52
- * primitives. Used to guard against malformed/hand-edited settings.json
53
- * content (e.g. `"decorations": "oops"` or `"decorations": [1,2,3]`) before
54
- * spreading it onto the defaults, so a wrong-shaped field can't leak stray
55
- * properties (like numeric-string keys from spreading a string) into the
56
- * merged settings object.
57
- */
58
- function isPlainObject(value: unknown): value is Record<string, unknown> {
59
- return typeof value === "object" && value !== null && !Array.isArray(value);
60
- }
61
-
62
- /**
63
- * Merge persisted JSON values onto a defaults object, accepting both boolean
64
- * fields and a known enum-typed string field (`meterDirection`). Any unknown
65
- * or wrong-typed keys are silently ignored so hand-edited settings.json can't
66
- * corrupt the resulting shape.
67
- */
68
- function mergeDecorations(
69
- defaults: DecoratorSettings["decorations"],
70
- parsed: unknown,
71
- ): DecoratorSettings["decorations"] {
43
+ export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
44
+ function isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
45
+ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, parsed: unknown): T {
72
46
  const merged = { ...defaults };
73
47
  if (!isPlainObject(parsed)) return merged;
74
48
  for (const [key, value] of Object.entries(parsed)) {
75
- if (key === "meterDirection") {
76
- if (value === "ltr" || value === "rtl") {
77
- merged.meterDirection = value;
78
- }
79
- } else if (typeof value === "boolean" && key in merged) {
80
- (merged as Record<string, unknown>)[key] = value;
81
- }
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;
82
53
  }
83
54
  return merged;
84
55
  }
85
56
 
86
- function mergeBooleanGroup<T extends Record<string, boolean>>(defaults: T, parsed: unknown): T {
87
- const merged = { ...defaults } as T;
88
- if (!isPlainObject(parsed)) return merged;
89
- for (const [key, value] of Object.entries(parsed)) {
90
- if (typeof value === "boolean" && key in merged) {
91
- merged[key as keyof T] = value as T[keyof T];
92
- }
93
- }
94
- return merged;
57
+ /** Map a persisted setting value to its menu cycle label (directions only; colors pass through). */
58
+ function toCycleValue(stored: unknown): string {
59
+ if (stored === "ltr") return "Left to Right";
60
+ if (stored === "rtl") return "Right to Left";
61
+ return String(stored);
95
62
  }
96
63
 
97
- interface DecorationBooleanMenuEntry {
98
- id: "animatedSpinner" | "shimmer" | "tokenActivityMonitor";
99
- label: string;
100
- section: "Decorations";
101
- group: "decorations";
64
+ /** Map a menu cycle label back to its persisted value. */
65
+ function fromCycleValue(value: string): string {
66
+ if (value === "Left to Right") return "ltr";
67
+ if (value === "Right to Left") return "rtl";
68
+ return value;
102
69
  }
103
70
 
104
- interface FeatureBooleanMenuEntry {
105
- id: "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "doneMarker";
106
- label: string;
107
- section: "Options";
108
- group: "features";
109
- }
71
+ type MenuSectionName = "User Prompt" | "Working Loader Text" | "Completion Marker" | "Options";
72
+ type DecorationSettings = DecoratorSettings["decorations"];
73
+ type FeatureSettings = DecoratorSettings["features"];
74
+ type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
75
+ type MenuEntryBase = { id: string; label: string; section: MenuSectionName };
76
+ type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string };
77
+ type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleEnabledBy?: never; cycleDisabledValue?: never };
78
+ type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
79
+ export const MENU_ENTRIES: readonly MenuEntry[] = [
80
+ { 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" },
82
+ { id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
83
+ { 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" },
95
+ { id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
96
+ { id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
97
+ { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
98
+ { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
99
+ { id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
100
+ ];
110
101
 
111
- interface DirectionMenuEntry {
112
- id: "meterDirection_rtl";
113
- label: string;
114
- section: "Options";
115
- group: "decorations";
102
+ function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["items"][number] {
103
+ const value = entry.group === "decorations" ? settings.decorations[entry.key] : settings.features[entry.key];
104
+ const cycleEnabled = entry.group === "decorations" && entry.cycleEnabledBy
105
+ ? settings.decorations[entry.cycleEnabledBy]
106
+ : undefined;
107
+ return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, value: entry.cycleValues ? toCycleValue(value) : value };
116
108
  }
117
109
 
118
- type MenuEntry = DecorationBooleanMenuEntry | FeatureBooleanMenuEntry | DirectionMenuEntry;
110
+ function isDecorationBooleanKey(key: keyof DecorationSettings): key is DecorationBooleanKey {
111
+ return typeof DEFAULT_SETTINGS.decorations[key] === "boolean";
112
+ }
119
113
 
120
- /** The ordered settings-menu schema and its explicit settings conversions. */
121
- export const MENU_ENTRIES: readonly MenuEntry[] = [
122
- { id: "animatedSpinner", label: "Animated spinner", section: "Decorations", group: "decorations" },
123
- { id: "shimmer", label: "“Working...” text shimmer", section: "Decorations", group: "decorations" },
124
- { id: "tokenActivityMonitor", label: "Token activity monitor", section: "Decorations", group: "decorations" },
125
- { id: "substituteDefaultMessage", label: "Substitute Pi's “Working…” message", section: "Options", group: "features" },
126
- { id: "elapsedTime", label: "Elapsed time since prompt", section: "Options", group: "features" },
127
- { id: "outputTokens", label: "Show output tokens", section: "Options", group: "features" },
128
- { id: "doneMarker", label: "Show completion marker", section: "Options", group: "features" },
129
- { id: "meterDirection_rtl", label: "Scrolling: Right → Left", section: "Options", group: "decorations" },
130
- ];
114
+ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof DecorationSettings, value: string): void {
115
+ const stored = fromCycleValue(value);
116
+ switch (key) {
117
+ case "borderColor":
118
+ case "spinnerColor":
119
+ case "meterColor":
120
+ if (stored === "accent" || stored === "border" || stored === "borderAccent") decorations[key] = stored;
121
+ return;
122
+ case "shimmerDirection":
123
+ case "meterDirection":
124
+ if (stored === "ltr" || stored === "rtl") decorations[key] = stored;
125
+ return;
126
+ default:
127
+ // Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here.
128
+ throw new Error(`Unhandled cycle setting: ${String(key)}`);
129
+ }
130
+ }
131
131
 
132
- /** Build the ordered toggle-menu sections from persisted settings. */
133
132
  export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
134
- return ["Decorations", "Options"].map((title) => ({
135
- title,
136
- items: MENU_ENTRIES.filter((entry) => entry.section === title).map((entry) => ({
137
- id: entry.id,
138
- label: entry.label,
139
- value:
140
- entry.id === "meterDirection_rtl"
141
- ? settings.decorations.meterDirection === "rtl"
142
- : entry.group === "decorations"
143
- ? settings.decorations[entry.id]
144
- : settings.features[entry.id],
145
- })),
146
- }));
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)) }));
147
134
  }
148
135
 
149
- /** Clone settings and apply recognized menu values, including direction conversion. */
150
- export function applyMenuResult(
151
- settings: DecoratorSettings,
152
- values: Record<string, boolean>,
153
- ): DecoratorSettings {
136
+ export function applyMenuResult(settings: DecoratorSettings, values: Record<string, boolean | string>): DecoratorSettings {
154
137
  const next = structuredClone(settings);
155
138
  for (const entry of MENU_ENTRIES) {
156
139
  const value = values[entry.id];
157
140
  if (value === undefined) continue;
158
- if (entry.id === "meterDirection_rtl") {
159
- next.decorations.meterDirection = value ? "rtl" : "ltr";
160
- } else if (entry.group === "decorations") {
161
- next.decorations[entry.id] = value;
162
- } else {
163
- next.features[entry.id] = value;
141
+ if (entry.group === "decorations" && entry.cycleValues && typeof value === "string" && entry.cycleValues.includes(value)) {
142
+ setDecorationCycleValue(next.decorations, entry.key, value);
143
+ if (entry.cycleEnabledBy) next.decorations[entry.cycleEnabledBy] = values[entry.cycleEnabledBy] !== false;
144
+ } else if (entry.group === "decorations" && typeof value === "boolean" && isDecorationBooleanKey(entry.key)) {
145
+ next.decorations[entry.key] = value;
146
+ } else if (entry.group === "features" && typeof value === "boolean") {
147
+ next.features[entry.key] = value;
164
148
  }
165
149
  }
166
150
  return next;
167
151
  }
168
152
 
169
- function cloneDefaults(): DecoratorSettings {
170
- return structuredClone(DEFAULT_SETTINGS);
171
- }
172
-
173
- /**
174
- * Load settings from disk, deep-merging any persisted values onto the
175
- * defaults. Returns a fresh clone of `DEFAULT_SETTINGS` if the file is
176
- * missing or contains malformed JSON -- this function never throws.
177
- */
178
153
  export function loadSettings(): DecoratorSettings {
179
- const defaults = cloneDefaults();
180
- let raw: string;
181
- try {
182
- raw = readFileSync(settingsPath(), "utf8");
183
- } catch {
184
- return defaults;
185
- }
186
-
187
154
  try {
188
- const parsed = JSON.parse(raw) as Partial<DecoratorSettings> | null;
189
- if (!isPlainObject(parsed)) return defaults;
190
- return {
191
- decorations: mergeDecorations(defaults.decorations, parsed.decorations),
192
- features: mergeBooleanGroup(defaults.features, parsed.features),
193
- };
194
- } catch {
195
- return defaults;
196
- }
155
+ const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
156
+ 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;
167
+ }
168
+ return settings;
169
+ } catch { return structuredClone(DEFAULT_SETTINGS); }
197
170
  }
198
-
199
- /**
200
- * Persist settings to disk, creating the parent directory if needed.
201
- * Writes to a temp file and renames it into place so a crash mid-write
202
- * (kill -9, OOM, disk full) can never leave `settings.json` truncated or
203
- * corrupt -- `renameSync` is atomic on POSIX filesystems when source and
204
- * destination are on the same directory/filesystem, which they always are
205
- * here. A stale `.tmp` file left behind by a crash is harmless: the next
206
- * successful `saveSettings()` call overwrites it, and `loadSettings()`
207
- * never reads it.
208
- */
209
171
  export function saveSettings(settings: DecoratorSettings): void {
210
- const path = settingsPath();
211
- mkdirSync(dirname(path), { recursive: true });
212
- const tmpPath = `${path}.tmp`;
213
- writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`);
214
- renameSync(tmpPath, path);
172
+ const path = settingsPath(); mkdirSync(dirname(path), { recursive: true });
173
+ writeFileSync(`${path}.tmp`, `${JSON.stringify(settings, null, 2)}\n`); renameSync(`${path}.tmp`, path);
215
174
  }