@underactive/pi-topping 0.1.1 → 0.2.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/menu.ts CHANGED
@@ -136,23 +136,45 @@ export class MenuComponent implements Component {
136
136
  }
137
137
 
138
138
  handleInput(data: string): void {
139
- if (this.flat.length === 0) return;
140
-
141
- if (matchesKey(data, Key.up)) {
142
- this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
143
- this.invalidate();
144
- } else if (matchesKey(data, Key.down)) {
145
- this.cursor = (this.cursor + 1) % this.flat.length;
146
- this.invalidate();
147
- } else if (matchesKey(data, Key.space)) {
148
- const item = this.flat[this.cursor]!;
149
- this.values[item.id] = !this.values[item.id];
150
- this.invalidate();
151
- } else if (matchesKey(data, Key.enter)) {
152
- this.done({ applied: true, values: { ...this.values } });
153
- } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
154
- this.done({ applied: false, values: { ...this.initialValues } });
139
+ if (this.flat.length === 0) {
140
+ if (matchesKey(data, Key.enter)) {
141
+ this.done({ applied: true, values: { ...this.values } });
142
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
143
+ this.done({ applied: false, values: { ...this.initialValues } });
144
+ }
145
+ return;
146
+ }
147
+
148
+ // Map input to a normalized key name.
149
+ let mappedKey: string | undefined;
150
+ for (const k of [Key.up, Key.down, Key.space, Key.enter, Key.escape]) {
151
+ if (matchesKey(data, k)) {
152
+ mappedKey = k;
153
+ break;
154
+ }
155
155
  }
156
+ if (!mappedKey && matchesKey(data, Key.ctrl("c"))) mappedKey = Key.escape;
157
+
158
+ const keyActions: Record<string, () => void> = {
159
+ [Key.up]: () => {
160
+ this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
161
+ this.invalidate();
162
+ },
163
+ [Key.down]: () => {
164
+ this.cursor = (this.cursor + 1) % this.flat.length;
165
+ this.invalidate();
166
+ },
167
+ [Key.space]: () => {
168
+ const item = this.flat[this.cursor]!;
169
+ this.values[item.id] = !this.values[item.id];
170
+ this.invalidate();
171
+ },
172
+ [Key.enter]: () => this.done({ applied: true, values: { ...this.values } }),
173
+ [Key.escape]: () => this.done({ applied: false, values: { ...this.initialValues } }),
174
+ };
175
+
176
+ const handler = mappedKey ? keyActions[mappedKey] : undefined;
177
+ if (handler) handler();
156
178
  }
157
179
 
158
180
  invalidate(): void {
@@ -186,42 +208,33 @@ export class MenuComponent implements Component {
186
208
  return 2 + Math.max(...candidates);
187
209
  }
188
210
 
189
- private buildLines(maxWidth: number): string[] {
190
- // Computed once per render so a stateful `preview` callback (e.g. one
191
- // driving a rolling activity meter) is never sampled twice per frame.
192
- const previewLines = this.previewFn
193
- ? this.previewFn(this.values, this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0)
194
- : undefined;
195
-
196
- const desired = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, this.preferredWidth(previewLines)));
197
- const boxWidth = Math.max(0, Math.min(desired, maxWidth));
198
- const innerWidth = Math.max(0, boxWidth - 2);
199
-
200
- const lines: string[] = [];
201
- lines.push(this.renderTopBorder(innerWidth));
211
+ private samplePreview(): string[] | undefined {
212
+ return this.previewFn ? this.previewFn(this.values, this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0) : undefined;
213
+ }
202
214
 
203
- if (previewLines && previewLines.length > 0) {
204
- lines.push(this.renderSectionDivider("Preview", innerWidth));
205
- lines.push(this.renderBlankRow(innerWidth));
206
- for (const line of previewLines) lines.push(this.renderContentRow(` ${line}`, innerWidth));
207
- lines.push(this.renderBlankRow(innerWidth));
208
- }
215
+ private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
216
+ if (!previewLines?.length) return [];
217
+ return [this.renderSectionDivider("Preview", innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
218
+ }
209
219
 
220
+ private buildToggleSections(innerWidth: number): string[] {
210
221
  let flatIndex = 0;
211
- for (const section of this.sections) {
212
- lines.push(this.renderSectionDivider(section.title, innerWidth));
213
- for (const item of section.items) {
214
- lines.push(this.renderItemRow(item, flatIndex === this.cursor, innerWidth));
215
- flatIndex++;
216
- }
217
- lines.push(this.renderBlankRow(innerWidth));
218
- }
222
+ return this.sections.flatMap(section => [
223
+ this.renderSectionDivider(section.title, innerWidth),
224
+ ...section.items.map(item => this.renderItemRow(item, flatIndex++ === this.cursor, innerWidth)),
225
+ this.renderBlankRow(innerWidth),
226
+ ]);
227
+ }
219
228
 
220
- lines.push(this.renderSeparator(innerWidth));
221
- lines.push(this.renderHintsRow(innerWidth));
222
- lines.push(this.renderBottomBorder(innerWidth));
229
+ private buildFooter(innerWidth: number): string[] {
230
+ return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)];
231
+ }
223
232
 
224
- return lines.map((line) => truncateToWidth(line, boxWidth, ""));
233
+ private buildLines(maxWidth: number): string[] {
234
+ const previewLines = this.samplePreview();
235
+ const boxWidth = Math.max(0, Math.min(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, this.preferredWidth(previewLines))), maxWidth));
236
+ const innerWidth = Math.max(0, boxWidth - 2);
237
+ return [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth), ...this.buildToggleSections(innerWidth), ...this.buildFooter(innerWidth)].map(line => truncateToWidth(line, boxWidth, ""));
225
238
  }
226
239
 
227
240
  private wrap(left: string, content: string, right: string): string {
@@ -229,13 +242,34 @@ export class MenuComponent implements Component {
229
242
  return th.fg("accent", left) + content + th.fg("accent", right);
230
243
  }
231
244
 
232
- private renderTopBorder(innerWidth: number): string {
245
+ /** Render a filled horizontal line with optional title. */
246
+ private renderFilledLine(
247
+ left: string,
248
+ right: string,
249
+ fillChar: string,
250
+ innerWidth: number,
251
+ title?: string,
252
+ titlePrefix?: string,
253
+ titleSuffix?: string,
254
+ boldTitle?: boolean,
255
+ ): string {
233
256
  const th = this.theme;
234
- const maxTitleLen = Math.max(0, innerWidth - 5);
235
- const title = this.title.length > maxTitleLen ? truncateToWidth(this.title, maxTitleLen) : this.title;
236
- const fillCount = Math.max(0, innerWidth - 5 - visibleWidth(title));
237
- const content = th.fg("accent", `\u2550[ ${th.bold(title)} `) + th.fg("accent", `]${"\u2550".repeat(fillCount)}`);
238
- return this.wrap("\u2554", content, "\u2557");
257
+ if (!title) {
258
+ const content = th.fg("accent", fillChar.repeat(innerWidth));
259
+ return this.wrap(left, content, right);
260
+ }
261
+ const prefix = titlePrefix ?? "";
262
+ const suffix = titleSuffix ?? "";
263
+ const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
264
+ const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
265
+ const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
266
+ const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
267
+ const content = th.fg("accent", `${prefix}${styledTitle}${suffix}${fillChar.repeat(fillCount)}`);
268
+ return this.wrap(left, content, right);
269
+ }
270
+
271
+ private renderTopBorder(innerWidth: number): string {
272
+ return this.renderFilledLine("\u2554", "\u2557", "\u2550", innerWidth, this.title, "\u2550[ ", " ]", true);
239
273
  }
240
274
 
241
275
  private renderBottomBorder(innerWidth: number): string {
@@ -246,17 +280,11 @@ export class MenuComponent implements Component {
246
280
  }
247
281
 
248
282
  private renderSectionDivider(title: string, innerWidth: number): string {
249
- const th = this.theme;
250
- const maxTitleLen = Math.max(0, innerWidth - 3);
251
- const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
252
- const fillCount = Math.max(0, innerWidth - 3 - visibleWidth(shownTitle));
253
- const content = th.fg("accent", `\u2500 ${shownTitle} ${"\u2500".repeat(fillCount)}`);
254
- return this.wrap("\u255f", content, "\u2562");
283
+ return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth, title, "\u2500 ", " \u2500");
255
284
  }
256
285
 
257
286
  private renderSeparator(innerWidth: number): string {
258
- const content = this.theme.fg("accent", "\u2500".repeat(innerWidth));
259
- return this.wrap("\u255f", content, "\u2562");
287
+ return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth);
260
288
  }
261
289
 
262
290
  private renderBlankRow(innerWidth: number): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A Pi extension that replaces the default \"Working...\" loader with a shimmering, randomized activity word, live elapsed timer, and output-token activity meter",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
package/preview.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
3
+ import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString } from "./format.ts";
4
+ import { pickRandomWord } from "./words.ts";
5
+
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const METER_INTERVAL_MS = 100;
8
+ const METER_PERIOD_MS = 2400;
9
+ const METER_PEAK_RATE = 46;
10
+ const TOKEN_RATE_PER_SEC = 28;
11
+ const DEFAULT_WORKING_WORD = "Working…";
12
+
13
+ function meterRate(elapsedMs: number): number {
14
+ return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE;
15
+ }
16
+
17
+ /** Stateful, per-menu live preview renderer. */
18
+ export class PreviewRenderer {
19
+ readonly #word = pickRandomWord();
20
+ readonly #meter = new ActivityMeter();
21
+ #lastMeterUpdate = 0;
22
+ readonly #ctx: ExtensionContext;
23
+
24
+ constructor(ctx: ExtensionContext) { this.#ctx = ctx; }
25
+
26
+ render(values: Record<string, boolean>, elapsedMs: number): string[] {
27
+ this.#meter.setDirection(values.meterDirection_rtl ? "rtl" : "ltr");
28
+ if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
29
+ this.#meter.push(rateToLevel(meterRate(elapsedMs)));
30
+ this.#lastMeterUpdate = elapsedMs;
31
+ }
32
+ const features = {
33
+ substituteDefaultMessage: values.substituteDefaultMessage ?? true,
34
+ elapsedTime: values.elapsedTime ?? true,
35
+ outputTokens: values.outputTokens ?? true,
36
+ };
37
+ const decorations = { shimmer: values.shimmer ?? true, tokenActivityMonitor: values.tokenActivityMonitor ?? true };
38
+ const spinner = values.animatedSpinner
39
+ ? this.#ctx.ui.theme.fg("accent", SPINNER_FRAMES[Math.floor(elapsedMs / 80) % SPINNER_FRAMES.length]!)
40
+ : "";
41
+ if (isFullyDefaultAppearance(features, decorations)) return [[spinner, this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD)].filter(Boolean).join(" ")];
42
+ const word = features.substituteDefaultMessage ? this.#word : DEFAULT_WORKING_WORD;
43
+ const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme) : this.#ctx.ui.theme.fg("text", word);
44
+ const meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme)) : "";
45
+ return [[spinner, buildWorkingMessage(this.#ctx.ui.theme, styledWord, meter, formatElapsed(elapsedMs), formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC))), features)].filter(Boolean).join(" ")];
46
+ }
47
+ }
package/session.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
4
+ import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, StreamingWordCounter } from "./format.ts";
5
+ import { showMenu } from "./menu.ts";
6
+ import { applyMenuResult, buildMenuSections, loadSettings, saveSettings } from "./settings.ts";
7
+ import { PreviewRenderer } from "./preview.ts";
8
+ import { pickRandomWord, pickRawWord } from "./words.ts";
9
+
10
+ const DONE_ENTRY_TYPE = "pi-topping-done";
11
+ const DEFAULT_WORKING_WORD = "Working…";
12
+ const SHIMMER_INTERVAL = 50;
13
+ const METER_INTERVAL_MS = 100;
14
+ export interface DoneEntryData { word: string; elapsedMs: number; }
15
+ export interface SessionState { startTime: number; currentWord: string; confirmTokens: number; liveTokens: number; shimmerOrigin: number; activityMeter: ActivityMeter; rateTracker: TokRateTracker; lastActivityMeterUpdate: number; timer: ReturnType<typeof setInterval> | null; busy: boolean; }
16
+ export function makeFreshState(): SessionState { return { startTime: 0, currentWord: "", confirmTokens: 0, liveTokens: 0, shimmerOrigin: 0, activityMeter: new ActivityMeter(), rateTracker: new TokRateTracker(), lastActivityMeterUpdate: 0, timer: null, busy: false }; }
17
+
18
+ /** Owns mutable extension state and Pi lifecycle registrations. */
19
+ export class SessionManager {
20
+ #counter = new StreamingWordCounter(); #state = makeFreshState(); #settings = loadSettings(); #currentCtx: ExtensionContext | null = null;
21
+ readonly #pi: ExtensionAPI;
22
+ constructor(pi: ExtensionAPI) { this.#pi = pi; }
23
+ install(): void {
24
+ this.#pi.on("session_start", async (_e, ctx) => { this.stopTimer(); this.#counter.reset(); this.#currentCtx = null; this.#state = makeFreshState(); this.#settings = loadSettings(); this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection); this.#currentCtx = ctx; if (this.usable(ctx)) this.applyIndicator(ctx); });
25
+ this.#pi.on("input", async (_e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx)) this.resetTurn(Date.now()); });
26
+ this.#pi.on("agent_start", async (_e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx)) return; if (!this.#state.startTime) this.resetTurn(Date.now()); this.#state.busy = true; this.startTimer(); this.tick(); });
27
+ this.#pi.on("message_start", async (e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx) && e.message.role === "assistant") { this.#state.liveTokens = 0; this.#counter.reset(); } });
28
+ this.#pi.on("message_update", async (e, ctx) => { this.#currentCtx = ctx; const a = e.assistantMessageEvent; if (this.usable(ctx) && a && (a.type === "text_delta" || a.type === "thinking_delta")) this.#state.liveTokens += this.#counter.count(a.delta, a.type); });
29
+ this.#pi.on("message_end", async (e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx) || e.message.role !== "assistant") return; this.#state.confirmTokens += e.message.usage?.output ?? this.#state.liveTokens; this.#state.liveTokens = 0; this.#counter.reset(); });
30
+ this.#pi.on("tool_execution_start", async (_e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx)) { this.#state.currentWord = pickRandomWord(); this.#state.shimmerOrigin = Date.now(); } });
31
+ this.#pi.on("agent_settled", async (_e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx)) return; const hadPrompt = !!this.#state.startTime; const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0; this.#state.busy = false; this.#state.startTime = 0; this.#counter.reset(); this.stopTimer(); ctx.ui.setWorkingMessage(); this.applyIndicator(ctx); this.#state.activityMeter.reset(); this.#state.rateTracker.reset(); this.#state.lastActivityMeterUpdate = 0; if (this.#settings.features.doneMarker && hadPrompt) this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, { word: pickRawWord().past_tense, elapsedMs }); this.#currentCtx = null; });
32
+ this.#pi.on("session_shutdown", async () => this.stopTimer());
33
+ this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => entry.data ? new Text(`${theme.fg("text", "π")}${theme.fg("dim", ` ${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}`)}`) : undefined);
34
+ this.#pi.registerCommand("topping-settings", { description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.", handler: async (_a, ctx) => this.showSettings(ctx) });
35
+ }
36
+ private usable(ctx: ExtensionContext): boolean { return !!ctx.hasUI && ctx.mode !== "print"; }
37
+ private applyIndicator(ctx: ExtensionContext): void { ctx.ui.setWorkingIndicator(this.#settings.decorations.animatedSpinner ? undefined : { frames: [] }); }
38
+ private stopTimer(): void { if (this.#state.timer) { clearInterval(this.#state.timer); this.#state.timer = null; } }
39
+ private startTimer(): void { if (!this.#state.timer) this.#state.timer = setInterval(() => this.tick(), SHIMMER_INTERVAL); }
40
+ private resetTurn(now: number): void { const s = this.#state; s.startTime = now; s.shimmerOrigin = now; s.currentWord = pickRandomWord(); s.confirmTokens = 0; s.liveTokens = 0; s.activityMeter.reset(); s.rateTracker.reset(); s.lastActivityMeterUpdate = 0; this.#counter.reset(); }
41
+ private tick(): void { const ctx = this.#currentCtx, s = this.#state; if (!s.busy || !ctx) return; const now = Date.now(), total = s.confirmTokens + s.liveTokens; if (now - s.lastActivityMeterUpdate >= METER_INTERVAL_MS) { s.activityMeter.push(rateToLevel(s.rateTracker.sample(total, now))); s.lastActivityMeterUpdate = now; } const f = this.#settings.features, d = this.#settings.decorations; if (isFullyDefaultAppearance(f, d)) return ctx.ui.setWorkingMessage(); const word = f.substituteDefaultMessage ? s.currentWord : DEFAULT_WORKING_WORD; const styled = d.shimmer ? shimmerString(word, now - s.shimmerOrigin, ctx.ui.theme) : ctx.ui.theme.fg("text", word); const meter = d.tokenActivityMonitor ? s.activityMeter.render((l, c) => ActivityMeter.colorizeCell(l, c, ctx.ui.theme)) : ""; ctx.ui.setWorkingMessage(buildWorkingMessage(ctx.ui.theme, styled, meter, formatElapsed(now - s.startTime), formatTokens(total), f)); }
42
+ private async showSettings(ctx: ExtensionCommandContext): Promise<void> { if (ctx.mode !== "tui") { ctx.ui.notify("/topping-settings requires TUI mode", "error"); return; } const before = this.#settings.decorations.animatedSpinner; const preview = new PreviewRenderer(ctx); const result = await showMenu<Record<string, boolean>>(ctx, { title: "Pi Topping: Settings", sections: buildMenuSections(this.#settings), hints: ["↑↓ move", "␣ toggle", "⏎ apply", "esc cancel"], preview: preview.render.bind(preview) }); if (!result.applied) return; this.#settings = applyMenuResult(this.#settings, result.values); saveSettings(this.#settings); if (before !== this.#settings.decorations.animatedSpinner) this.applyIndicator(ctx); this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection); ctx.ui.notify("Pi Topping settings saved", "info"); }
43
+ }
package/settings.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * settings.ts
3
3
  *
4
4
  * Persistent, per-user settings for pi-topping, stored at
5
- * `~/.pi/agent/pi-topping/settings.json`. All six toggles
5
+ * `~/.pi/agent/pi-topping/settings.json`. All seven toggles
6
6
  * default to enabled so behavior is unchanged for users who never open
7
7
  * `/topping-settings`.
8
8
  */
@@ -10,17 +10,20 @@
10
10
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
11
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
+ import type { MenuSection } from "./menu.ts";
13
14
 
14
15
  export interface DecoratorSettings {
15
16
  decorations: {
16
17
  animatedSpinner: boolean;
17
18
  shimmer: boolean;
18
19
  tokenActivityMonitor: boolean;
20
+ meterDirection: "ltr" | "rtl";
19
21
  };
20
22
  features: {
21
23
  substituteDefaultMessage: boolean;
22
24
  elapsedTime: boolean;
23
25
  outputTokens: boolean;
26
+ doneMarker: boolean;
24
27
  };
25
28
  }
26
29
 
@@ -29,11 +32,13 @@ export const DEFAULT_SETTINGS: DecoratorSettings = {
29
32
  animatedSpinner: true,
30
33
  shimmer: true,
31
34
  tokenActivityMonitor: true,
35
+ meterDirection: "ltr",
32
36
  },
33
37
  features: {
34
38
  substituteDefaultMessage: true,
35
39
  elapsedTime: true,
36
40
  outputTokens: true,
41
+ doneMarker: true,
37
42
  },
38
43
  };
39
44
 
@@ -54,11 +59,115 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
54
59
  return typeof value === "object" && value !== null && !Array.isArray(value);
55
60
  }
56
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"] {
72
+ const merged = { ...defaults };
73
+ if (!isPlainObject(parsed)) return merged;
74
+ 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
+ }
82
+ }
83
+ return merged;
84
+ }
85
+
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;
95
+ }
96
+
97
+ interface DecorationBooleanMenuEntry {
98
+ id: "animatedSpinner" | "shimmer" | "tokenActivityMonitor";
99
+ label: string;
100
+ section: "Decorations";
101
+ group: "decorations";
102
+ }
103
+
104
+ interface FeatureBooleanMenuEntry {
105
+ id: "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "doneMarker";
106
+ label: string;
107
+ section: "Options";
108
+ group: "features";
109
+ }
110
+
111
+ interface DirectionMenuEntry {
112
+ id: "meterDirection_rtl";
113
+ label: string;
114
+ section: "Options";
115
+ group: "decorations";
116
+ }
117
+
118
+ type MenuEntry = DecorationBooleanMenuEntry | FeatureBooleanMenuEntry | DirectionMenuEntry;
119
+
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
+ ];
131
+
132
+ /** Build the ordered toggle-menu sections from persisted settings. */
133
+ 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
+ }));
147
+ }
148
+
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 {
154
+ const next = structuredClone(settings);
155
+ for (const entry of MENU_ENTRIES) {
156
+ const value = values[entry.id];
157
+ 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;
164
+ }
165
+ }
166
+ return next;
167
+ }
168
+
57
169
  function cloneDefaults(): DecoratorSettings {
58
- return {
59
- decorations: { ...DEFAULT_SETTINGS.decorations },
60
- features: { ...DEFAULT_SETTINGS.features },
61
- };
170
+ return structuredClone(DEFAULT_SETTINGS);
62
171
  }
63
172
 
64
173
  /**
@@ -79,8 +188,8 @@ export function loadSettings(): DecoratorSettings {
79
188
  const parsed = JSON.parse(raw) as Partial<DecoratorSettings> | null;
80
189
  if (!isPlainObject(parsed)) return defaults;
81
190
  return {
82
- decorations: { ...defaults.decorations, ...(isPlainObject(parsed.decorations) ? parsed.decorations : undefined) },
83
- features: { ...defaults.features, ...(isPlainObject(parsed.features) ? parsed.features : undefined) },
191
+ decorations: mergeDecorations(defaults.decorations, parsed.decorations),
192
+ features: mergeBooleanGroup(defaults.features, parsed.features),
84
193
  };
85
194
  } catch {
86
195
  return defaults;