@underactive/pi-topping 0.2.0 → 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/session.ts ADDED
@@ -0,0 +1,370 @@
1
+ import type {
2
+ AgentSettledEvent,
3
+ AgentStartEvent,
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ ExtensionEvent,
8
+ InputEvent,
9
+ InputEventResult,
10
+ SessionShutdownEvent,
11
+ SessionStartEvent,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { Text } from "@earendil-works/pi-tui";
14
+ import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
15
+ import {
16
+ buildWorkingMessage,
17
+ formatElapsed,
18
+ formatTokens,
19
+ isFullyDefaultAppearance,
20
+ shimmerString,
21
+ SPINNER_FRAMES,
22
+ StreamingWordCounter,
23
+ } from "./format.ts";
24
+ import { showMenu } from "./menu.ts";
25
+ import { applyMenuResult, buildMenuSections, loadSettings, saveSettings } from "./settings.ts";
26
+ import { PreviewRenderer } from "./preview.ts";
27
+ import { PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
28
+ import { pickRandomWord, pickRawWord } from "./words.ts";
29
+
30
+ type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
31
+ type MessageUpdateEvent = Extract<ExtensionEvent, { type: "message_update" }>;
32
+ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
33
+ type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
34
+
35
+ const DONE_ENTRY_TYPE = "pi-topping-done";
36
+ const DEFAULT_WORKING_WORD = "Working…";
37
+ const SHIMMER_INTERVAL = 50;
38
+ const METER_INTERVAL_MS = 100;
39
+ const ELAPSED_INTERVAL_MS = 1_000;
40
+
41
+ export interface DoneEntryData {
42
+ word: string;
43
+ elapsedMs: number;
44
+ tokens: number;
45
+ }
46
+
47
+ export interface SessionState {
48
+ startTime: number;
49
+ currentWord: string;
50
+ confirmTokens: number;
51
+ liveTokens: number;
52
+ shimmerOrigin: number;
53
+ activityMeter: ActivityMeter;
54
+ rateTracker: TokRateTracker;
55
+ lastActivityMeterUpdate: number;
56
+ timer: ReturnType<typeof setInterval> | null;
57
+ busy: boolean;
58
+ }
59
+
60
+ export function makeFreshState(): SessionState {
61
+ return {
62
+ startTime: 0,
63
+ currentWord: "",
64
+ confirmTokens: 0,
65
+ liveTokens: 0,
66
+ shimmerOrigin: 0,
67
+ activityMeter: new ActivityMeter(),
68
+ rateTracker: new TokRateTracker(),
69
+ lastActivityMeterUpdate: 0,
70
+ timer: null,
71
+ busy: false,
72
+ };
73
+ }
74
+
75
+ /** Owns mutable extension state and Pi lifecycle registrations. */
76
+ export class SessionManager {
77
+ #counter = new StreamingWordCounter();
78
+ #state = makeFreshState();
79
+ #settings = loadSettings();
80
+ #currentCtx: ExtensionContext | null = null;
81
+ readonly #pi: ExtensionAPI;
82
+
83
+ constructor(pi: ExtensionAPI) {
84
+ this.#pi = pi;
85
+ }
86
+
87
+ #onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
88
+ this.stopTimer();
89
+ this.#counter.reset();
90
+ this.#currentCtx = null;
91
+ this.#state = makeFreshState();
92
+ this.#settings = loadSettings();
93
+ this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
94
+ this.#currentCtx = ctx;
95
+ if (this.usable(ctx)) this.applyIndicator(ctx);
96
+ };
97
+
98
+ #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
99
+ this.#currentCtx = ctx;
100
+ if (!this.usable(ctx)) return;
101
+
102
+ this.resetTurn(Date.now());
103
+ if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
104
+ this.#pi.sendMessage<PromptBoxDetails>(
105
+ {
106
+ customType: PROMPT_BOX_TYPE,
107
+ content: event.text,
108
+ display: true,
109
+ details: {
110
+ submittedAt: Date.now(),
111
+ showIcon: this.#settings.decorations.promptIcon,
112
+ showTimestamp: this.#settings.decorations.promptTimestamp,
113
+ icon: this.#settings.decorations.useNerdFont ? "" : "π",
114
+ borderColor: this.#settings.decorations.borderColor,
115
+ },
116
+ },
117
+ { triggerTurn: true },
118
+ );
119
+ return { action: "handled" };
120
+ }
121
+ };
122
+
123
+ #onAgentStart = async (_e: AgentStartEvent, ctx: ExtensionContext): Promise<void> => {
124
+ this.#currentCtx = ctx;
125
+ if (!this.usable(ctx)) return;
126
+ if (!this.#state.startTime) this.resetTurn(Date.now());
127
+ this.#state.busy = true;
128
+ this.startTimer();
129
+ this.tick();
130
+ };
131
+
132
+ #onMessageStart = async (event: MessageStartEvent, ctx: ExtensionContext): Promise<void> => {
133
+ this.#currentCtx = ctx;
134
+ if (this.usable(ctx) && event.message.role === "assistant") {
135
+ this.#state.liveTokens = 0;
136
+ this.#counter.reset();
137
+ }
138
+ };
139
+
140
+ #onMessageUpdate = async (event: MessageUpdateEvent, ctx: ExtensionContext): Promise<void> => {
141
+ this.#currentCtx = ctx;
142
+ const assistantEvent = event.assistantMessageEvent;
143
+ if (
144
+ this.usable(ctx) &&
145
+ assistantEvent &&
146
+ (assistantEvent.type === "text_delta" || assistantEvent.type === "thinking_delta")
147
+ ) {
148
+ this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
149
+ }
150
+ };
151
+
152
+ #onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
153
+ this.#currentCtx = ctx;
154
+ if (!this.usable(ctx) || event.message.role !== "assistant") return;
155
+
156
+ const exactTokens = event.message.usage?.output;
157
+ this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
158
+ if (exactTokens !== undefined) {
159
+ this.#state.rateTracker.reset();
160
+ this.#state.lastActivityMeterUpdate = 0;
161
+ }
162
+ this.#state.liveTokens = 0;
163
+ this.#counter.reset();
164
+ };
165
+
166
+ #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
167
+ this.#currentCtx = ctx;
168
+ if (this.usable(ctx)) {
169
+ this.#state.currentWord = pickRandomWord();
170
+ this.#state.shimmerOrigin = Date.now();
171
+ this.tick();
172
+ }
173
+ };
174
+
175
+ #onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
176
+ this.#currentCtx = ctx;
177
+ if (!this.usable(ctx)) return;
178
+
179
+ const hadPrompt = !!this.#state.startTime;
180
+ const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
181
+ this.#state.busy = false;
182
+ this.#state.startTime = 0;
183
+ this.#counter.reset();
184
+ this.stopTimer();
185
+ ctx.ui.setWorkingMessage();
186
+ this.applyIndicator(ctx);
187
+ this.#state.activityMeter.reset();
188
+ this.#state.rateTracker.reset();
189
+ this.#state.lastActivityMeterUpdate = 0;
190
+ if (this.#settings.features.doneMarker && hadPrompt) {
191
+ this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
192
+ word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
193
+ elapsedMs,
194
+ tokens: this.#state.confirmTokens,
195
+ });
196
+ }
197
+ this.#currentCtx = null;
198
+ };
199
+
200
+ #onSessionShutdown = async (_e: SessionShutdownEvent, _ctx: ExtensionContext): Promise<void> => {
201
+ this.stopTimer();
202
+ };
203
+
204
+ install(): void {
205
+ this.#pi.on("session_start", this.#onSessionStart);
206
+ this.#pi.on("input", this.#onInput);
207
+ this.#pi.on("agent_start", this.#onAgentStart);
208
+ this.#pi.on("message_start", this.#onMessageStart);
209
+ this.#pi.on("message_update", this.#onMessageUpdate);
210
+ this.#pi.on("message_end", this.#onMessageEnd);
211
+ this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
212
+ this.#pi.on("agent_settled", this.#onAgentSettled);
213
+ this.#pi.on("session_shutdown", this.#onSessionShutdown);
214
+ this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) =>
215
+ entry.data
216
+ ? new Text(
217
+ `${this.#settings.features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${this.#settings.features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${this.#settings.features.doneMarkerTokens && entry.data.tokens !== undefined ? ` (↓ ${formatTokens(entry.data.tokens)} tokens)` : ""}`)}`,
218
+ )
219
+ : undefined,
220
+ );
221
+ this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
222
+ this.#pi.registerCommand("topping-settings", {
223
+ description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
224
+ handler: async (_a, ctx) => this.showSettings(ctx),
225
+ });
226
+ }
227
+
228
+ private usable(ctx: ExtensionContext): boolean {
229
+ return !!ctx.hasUI && ctx.mode !== "print";
230
+ }
231
+
232
+ private shouldDecorate(event: InputEvent): boolean {
233
+ return event.source !== "extension" && !!event.text.trim() && !event.images?.length && !/^[\/!?:]/.test(event.text);
234
+ }
235
+
236
+ private applyIndicator(ctx: ExtensionContext): void {
237
+ const decorations = this.#settings.decorations;
238
+ if (!decorations.animatedSpinner) {
239
+ ctx.ui.setWorkingIndicator({ frames: [] });
240
+ return;
241
+ }
242
+
243
+ const color = decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
244
+ if (color === "accent") {
245
+ ctx.ui.setWorkingIndicator(undefined);
246
+ return;
247
+ }
248
+ ctx.ui.setWorkingIndicator({ frames: SPINNER_FRAMES.map((frame) => ctx.ui.theme.fg(color, frame)) });
249
+ }
250
+
251
+ private stopTimer(): void {
252
+ if (this.#state.timer) {
253
+ clearInterval(this.#state.timer);
254
+ this.#state.timer = null;
255
+ }
256
+ }
257
+
258
+ private startTimer(): void {
259
+ if (this.#state.timer) return;
260
+
261
+ const features = this.#settings.features;
262
+ const decorations = this.#settings.decorations;
263
+ const interval = decorations.shimmer
264
+ ? SHIMMER_INTERVAL
265
+ : (decorations.tokenActivityMonitor || features.outputTokens)
266
+ ? METER_INTERVAL_MS
267
+ : features.elapsedTime
268
+ ? ELAPSED_INTERVAL_MS
269
+ : undefined;
270
+ if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
271
+ }
272
+
273
+ private resetTurn(now: number): void {
274
+ const state = this.#state;
275
+ state.startTime = now;
276
+ state.shimmerOrigin = now;
277
+ state.currentWord = pickRandomWord();
278
+ state.confirmTokens = 0;
279
+ state.liveTokens = 0;
280
+ state.activityMeter.reset();
281
+ state.rateTracker.reset();
282
+ state.lastActivityMeterUpdate = 0;
283
+ this.#counter.reset();
284
+ }
285
+
286
+ private tick(): void {
287
+ const ctx = this.#currentCtx;
288
+ const state = this.#state;
289
+ if (!state.busy || !ctx) return;
290
+
291
+ const now = Date.now();
292
+ const total = state.confirmTokens + state.liveTokens;
293
+ const features = this.#settings.features;
294
+ const decorations = this.#settings.decorations;
295
+ if (decorations.tokenActivityMonitor && now - state.lastActivityMeterUpdate >= METER_INTERVAL_MS) {
296
+ state.activityMeter.push(rateToLevel(state.rateTracker.sample(total, now)));
297
+ state.lastActivityMeterUpdate = now;
298
+ }
299
+ if (isFullyDefaultAppearance(features, decorations)) {
300
+ ctx.ui.setWorkingMessage();
301
+ return;
302
+ }
303
+
304
+ const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
305
+ const styled = decorations.shimmer
306
+ ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection)
307
+ : ctx.ui.theme.fg("text", word);
308
+ const meter = decorations.tokenActivityMonitor
309
+ ? state.activityMeter.render((level, char) =>
310
+ ActivityMeter.colorizeCell(
311
+ level,
312
+ char,
313
+ ctx.ui.theme,
314
+ decorations.meterColorEnabled ? decorations.meterColor : "accent",
315
+ decorations.meterDimmed,
316
+ ),
317
+ )
318
+ : "";
319
+ ctx.ui.setWorkingMessage(
320
+ buildWorkingMessage(
321
+ ctx.ui.theme,
322
+ styled,
323
+ meter,
324
+ formatElapsed(now - state.startTime),
325
+ formatTokens(total),
326
+ features,
327
+ ),
328
+ );
329
+ }
330
+
331
+ private async showSettings(ctx: ExtensionCommandContext): Promise<void> {
332
+ if (ctx.mode !== "tui") {
333
+ ctx.ui.notify("/topping-settings requires TUI mode", "error");
334
+ return;
335
+ }
336
+
337
+ const before = `${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`;
338
+ const preview = new PreviewRenderer(ctx);
339
+ const result = await showMenu<Record<string, boolean | string>>(ctx, {
340
+ title: "Pi Topping: Settings",
341
+ sections: buildMenuSections(this.#settings),
342
+ hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
343
+ preview: preview.render.bind(preview),
344
+ });
345
+ if (!result.applied) return;
346
+
347
+ const updatedSettings = applyMenuResult(this.#settings, result.values);
348
+ try {
349
+ saveSettings(updatedSettings);
350
+ } catch {
351
+ ctx.ui.notify("Failed to save Pi Topping settings", "error");
352
+ return;
353
+ }
354
+
355
+ this.#settings = updatedSettings;
356
+ if (
357
+ before !==
358
+ `${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`
359
+ ) {
360
+ this.applyIndicator(ctx);
361
+ }
362
+ this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
363
+ if (this.#state.busy) {
364
+ this.stopTimer();
365
+ this.startTimer();
366
+ this.tick();
367
+ }
368
+ ctx.ui.notify("Pi Topping settings saved", "info");
369
+ }
370
+ }
@@ -0,0 +1,174 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import type { MenuSection } from "./menu.ts";
5
+
6
+ export interface DecoratorSettings {
7
+ decorations: {
8
+ animatedSpinner: boolean;
9
+ shimmer: boolean;
10
+ shimmerDirection: "ltr" | "rtl";
11
+ shimmerDirectionEnabled: boolean;
12
+ tokenActivityMonitor: boolean;
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;
26
+ };
27
+ features: {
28
+ substituteDefaultMessage: boolean;
29
+ elapsedTime: boolean;
30
+ outputTokens: boolean;
31
+ doneMarker: boolean;
32
+ doneMarkerIcon: boolean;
33
+ randomizeDoneMarker: boolean;
34
+ doneMarkerTokens: boolean;
35
+ };
36
+ }
37
+
38
+ 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 },
41
+ };
42
+
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 {
46
+ const merged = { ...defaults };
47
+ if (!isPlainObject(parsed)) return merged;
48
+ 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;
53
+ }
54
+ return merged;
55
+ }
56
+
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);
62
+ }
63
+
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;
69
+ }
70
+
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
+ ];
101
+
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 };
108
+ }
109
+
110
+ function isDecorationBooleanKey(key: keyof DecorationSettings): key is DecorationBooleanKey {
111
+ return typeof DEFAULT_SETTINGS.decorations[key] === "boolean";
112
+ }
113
+
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
+
132
+ 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)) }));
134
+ }
135
+
136
+ export function applyMenuResult(settings: DecoratorSettings, values: Record<string, boolean | string>): DecoratorSettings {
137
+ const next = structuredClone(settings);
138
+ for (const entry of MENU_ENTRIES) {
139
+ const value = values[entry.id];
140
+ if (value === undefined) continue;
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;
148
+ }
149
+ }
150
+ return next;
151
+ }
152
+
153
+ export function loadSettings(): DecoratorSettings {
154
+ try {
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); }
170
+ }
171
+ 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);
174
+ }