@underactive/pi-topping 0.4.0 → 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/session.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AgentSettledEvent,
3
3
  AgentStartEvent,
4
+ CustomEntry,
4
5
  ExtensionAPI,
5
6
  ExtensionCommandContext,
6
7
  ExtensionContext,
@@ -9,23 +10,33 @@ import type {
9
10
  InputEventResult,
10
11
  SessionShutdownEvent,
11
12
  SessionStartEvent,
13
+ Theme,
12
14
  } from "@earendil-works/pi-coding-agent";
13
- import { Text } from "@earendil-works/pi-tui";
15
+ import { Text, type Component } from "@earendil-works/pi-tui";
14
16
  import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
15
17
  import {
16
18
  buildWorkingMessage,
19
+ DEFAULT_WORKING_WORD,
20
+ dimAttribute,
21
+ ELAPSED_INTERVAL_MS,
22
+ fadeThemeColorString,
17
23
  formatElapsed,
24
+ formatTokenRate,
18
25
  formatTokens,
19
26
  isFullyDefaultAppearance,
27
+ METER_INTERVAL_MS,
28
+ SHIMMER_INTERVAL_MS,
20
29
  shimmerString,
21
30
  SPINNER_FRAME_MS,
22
31
  SPINNER_FRAMES,
32
+ TOKEN_RATE_FADE_SHADE_COUNT,
33
+ TOKEN_RATE_PLACEHOLDER,
23
34
  StreamingWordCounter,
24
35
  } from "./format.ts";
25
36
  import { showMenu } from "./menu.ts";
26
- import { applyMenuResult, buildMenuSections, loadSettings, saveSettings } from "./settings.ts";
37
+ import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type SettingColor } from "./settings.ts";
27
38
  import { PreviewRenderer } from "./preview.ts";
28
- import { PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
39
+ import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, stripControlChars, type PromptBoxDetails } from "./prompt-decorator.ts";
29
40
  import { pickRandomWord, pickRawWord } from "./words.ts";
30
41
 
31
42
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
@@ -34,19 +45,18 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
34
45
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
35
46
 
36
47
  const DONE_ENTRY_TYPE = "pi-topping-done";
37
- const DEFAULT_WORKING_WORD = "Working…";
38
- const SHIMMER_INTERVAL = 50;
39
- const METER_INTERVAL_MS = 100;
40
- const ELAPSED_INTERVAL_MS = 1_000;
48
+ // Reconciliation resets the EMA at message boundaries, so retain and then fade the last rate to avoid flicker.
49
+ const TOKEN_RATE_HOLD_MS = 1_500;
50
+ const TOKEN_RATE_FADE_MS = 250;
41
51
 
42
- export interface DoneEntryData {
52
+ interface DoneEntryData {
43
53
  word: string;
44
54
  elapsedMs: number;
45
55
  tokens?: number;
46
56
  midTurnInputs?: number;
47
57
  }
48
58
 
49
- export interface SessionState {
59
+ interface SessionState {
50
60
  startTime: number;
51
61
  currentWord: string;
52
62
  confirmTokens: number;
@@ -54,13 +64,30 @@ export interface SessionState {
54
64
  shimmerOrigin: number;
55
65
  activityMeter: ActivityMeter;
56
66
  rateTracker: TokRateTracker;
57
- lastActivityMeterUpdate: number;
67
+ lastTokenRateSampledAt: number;
68
+ lastTokenRateTotal: number;
69
+ tokenRateText: string;
70
+ tokenRateFadeStartsAt: number;
58
71
  timer: ReturnType<typeof setInterval> | null;
59
72
  busy: boolean;
60
73
  midTurnInputs: number;
74
+ /** Last string passed to setWorkingMessage(), or the NOT_SENT sentinel before the first call. */
75
+ lastMessage: string | undefined | typeof NOT_SENT;
61
76
  }
62
77
 
63
- export function makeFreshState(): SessionState {
78
+ /** Sentinel distinguishing "never called setWorkingMessage()" from an explicit `undefined` message. */
79
+ const NOT_SENT = Symbol("not-sent");
80
+
81
+ /** Reset all token-rate tracking fields on `state` to their fresh-turn values. */
82
+ function resetTokenRateState(state: SessionState): void {
83
+ state.rateTracker.reset();
84
+ state.lastTokenRateSampledAt = 0;
85
+ state.lastTokenRateTotal = 0;
86
+ state.tokenRateText = "";
87
+ state.tokenRateFadeStartsAt = 0;
88
+ }
89
+
90
+ function makeFreshState(): SessionState {
64
91
  return {
65
92
  startTime: 0,
66
93
  currentWord: "",
@@ -69,10 +96,14 @@ export function makeFreshState(): SessionState {
69
96
  shimmerOrigin: 0,
70
97
  activityMeter: new ActivityMeter(),
71
98
  rateTracker: new TokRateTracker(),
72
- lastActivityMeterUpdate: 0,
99
+ lastTokenRateSampledAt: 0,
100
+ lastTokenRateTotal: 0,
101
+ tokenRateText: "",
102
+ tokenRateFadeStartsAt: 0,
73
103
  timer: null,
74
104
  busy: false,
75
105
  midTurnInputs: 0,
106
+ lastMessage: NOT_SENT,
76
107
  };
77
108
  }
78
109
 
@@ -91,7 +122,6 @@ export class SessionManager {
91
122
  #onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
92
123
  this.stopTimer();
93
124
  this.#counter.reset();
94
- this.#currentCtx = null;
95
125
  this.#state = makeFreshState();
96
126
  this.#settings = loadSettings();
97
127
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
@@ -115,7 +145,11 @@ export class SessionManager {
115
145
  submittedAt: Date.now(),
116
146
  showIcon: this.#settings.decorations.promptIcon,
117
147
  showTimestamp: this.#settings.decorations.promptTimestamp,
148
+ showProvider: this.#settings.decorations.promptProvider,
149
+ showModel: this.#settings.decorations.promptModel,
118
150
  icon: this.#settings.decorations.useNerdFont ? "" : "π",
151
+ provider: ctx.model?.provider,
152
+ model: ctx.model?.id,
119
153
  borderColor: this.#settings.decorations.borderColor,
120
154
  borderStyle: this.#settings.decorations.borderStyle,
121
155
  },
@@ -165,8 +199,11 @@ export class SessionManager {
165
199
  const exactTokens = event.message.usage?.output;
166
200
  this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
167
201
  if (exactTokens !== undefined) {
202
+ // Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
203
+ // those must keep holding/fading across message boundaries to avoid flicker.
168
204
  this.#state.rateTracker.reset();
169
- this.#state.lastActivityMeterUpdate = 0;
205
+ this.#state.lastTokenRateSampledAt = 0;
206
+ this.#state.lastTokenRateTotal = this.#state.confirmTokens;
170
207
  }
171
208
  this.#state.liveTokens = 0;
172
209
  this.#counter.reset();
@@ -194,8 +231,8 @@ export class SessionManager {
194
231
  ctx.ui.setWorkingMessage();
195
232
  this.applyIndicator(ctx);
196
233
  this.#state.activityMeter.reset();
197
- this.#state.rateTracker.reset();
198
- this.#state.lastActivityMeterUpdate = 0;
234
+ resetTokenRateState(this.#state);
235
+ this.#state.lastMessage = NOT_SENT;
199
236
  if (this.#settings.features.doneMarker && hadPrompt) {
200
237
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
201
238
  word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
@@ -211,6 +248,39 @@ export class SessionManager {
211
248
  this.stopTimer();
212
249
  };
213
250
 
251
+ #renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
252
+ if (!entry.data) return undefined;
253
+ const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
254
+ const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
255
+ const features = this.#settings.features;
256
+ const details: string[] = [];
257
+ if (features.doneMarkerTokens && typeof entry.data.tokens === "number") details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
258
+ if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && entry.data.midTurnInputs) {
259
+ details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
260
+ }
261
+ const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
262
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
263
+ const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
264
+ const borderColor = this.#settings.decorations.doneMarkerBorderColor;
265
+ if (borderStyle === "none") return new Text(markerContent);
266
+
267
+ let cachedWidth: number | undefined;
268
+ let cachedLines: string[] | undefined;
269
+ return {
270
+ render(width: number): string[] {
271
+ if (cachedWidth !== width) {
272
+ cachedWidth = width;
273
+ cachedLines = ["", ` ${buildCompletionMarkerLine(markerContent, width - 2, theme, borderStyle, borderColor)}`, ""];
274
+ }
275
+ return cachedLines!;
276
+ },
277
+ invalidate(): void {
278
+ cachedWidth = undefined;
279
+ cachedLines = undefined;
280
+ },
281
+ } satisfies Component;
282
+ }
283
+
214
284
  install(): void {
215
285
  this.#pi.on("session_start", this.#onSessionStart);
216
286
  this.#pi.on("input", this.#onInput);
@@ -221,22 +291,10 @@ export class SessionManager {
221
291
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
222
292
  this.#pi.on("agent_settled", this.#onAgentSettled);
223
293
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
224
- this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => {
225
- if (!entry.data) return undefined;
226
- const features = this.#settings.features;
227
- const details: string[] = [];
228
- if (features.doneMarkerTokens && entry.data.tokens !== undefined) details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
229
- if (features.doneMarkerInputs && entry.data.midTurnInputs) {
230
- details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
231
- }
232
- const tail = details.length ? ` (${details.join(" · ")})` : "";
233
- return new Text(
234
- `${features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${tail}`)}`,
235
- );
236
- });
294
+ this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
237
295
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
238
296
  this.#pi.registerCommand("topping-settings", {
239
- description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
297
+ description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
240
298
  handler: async (_a, ctx) => this.showSettings(ctx),
241
299
  });
242
300
  }
@@ -257,7 +315,7 @@ export class SessionManager {
257
315
  return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
258
316
  }
259
317
 
260
- private spinnerColor(): "accent" | "border" | "borderAccent" {
318
+ private spinnerColor(): SettingColor {
261
319
  const decorations = this.#settings.decorations;
262
320
  return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
263
321
  }
@@ -293,13 +351,10 @@ export class SessionManager {
293
351
 
294
352
  const features = this.#settings.features;
295
353
  const decorations = this.#settings.decorations;
296
- let interval = decorations.shimmer
297
- ? SHIMMER_INTERVAL
298
- : (decorations.tokenActivityMonitor || features.outputTokens)
299
- ? METER_INTERVAL_MS
300
- : features.elapsedTime
301
- ? ELAPSED_INTERVAL_MS
302
- : undefined;
354
+ let interval: number | undefined;
355
+ if (decorations.shimmer) interval = SHIMMER_INTERVAL_MS;
356
+ else if (decorations.tokenActivityMonitor || features.outputTokens || features.tokenRate) interval = METER_INTERVAL_MS;
357
+ else if (features.elapsedTime) interval = ELAPSED_INTERVAL_MS;
303
358
  if (this.spinnerInMessage()) interval = Math.min(interval ?? SPINNER_FRAME_MS, SPINNER_FRAME_MS);
304
359
  if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
305
360
  }
@@ -312,9 +367,9 @@ export class SessionManager {
312
367
  state.confirmTokens = 0;
313
368
  state.liveTokens = 0;
314
369
  state.activityMeter.reset();
315
- state.rateTracker.reset();
316
- state.lastActivityMeterUpdate = 0;
370
+ resetTokenRateState(state);
317
371
  state.midTurnInputs = 0;
372
+ state.lastMessage = NOT_SENT;
318
373
  this.#counter.reset();
319
374
  }
320
375
 
@@ -327,25 +382,32 @@ export class SessionManager {
327
382
  const total = state.confirmTokens + state.liveTokens;
328
383
  const features = this.#settings.features;
329
384
  const decorations = this.#settings.decorations;
330
- if (decorations.tokenActivityMonitor && now - state.lastActivityMeterUpdate >= METER_INTERVAL_MS) {
331
- state.activityMeter.push(rateToLevel(state.rateTracker.sample(total, now)));
332
- state.lastActivityMeterUpdate = now;
385
+ let hasNewTokenCount = false;
386
+ if ((decorations.tokenActivityMonitor || features.tokenRate) && now - state.lastTokenRateSampledAt >= METER_INTERVAL_MS) {
387
+ // Do not reset the fade for an EMA-only decay while output is quiet.
388
+ hasNewTokenCount = total > state.lastTokenRateTotal;
389
+ state.lastTokenRateTotal = total;
390
+ const tokenRate = state.rateTracker.sample(total, now);
391
+ if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
392
+ state.lastTokenRateSampledAt = now;
333
393
  }
334
394
  const spinner = this.spinnerInMessage()
335
395
  ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
336
396
  : "";
337
397
  if (isFullyDefaultAppearance(features, decorations)) {
338
- ctx.ui.setWorkingMessage(
339
- spinner
340
- ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
341
- : undefined,
342
- );
398
+ const msg = spinner
399
+ ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
400
+ : undefined;
401
+ if (msg !== state.lastMessage) {
402
+ state.lastMessage = msg;
403
+ ctx.ui.setWorkingMessage(msg);
404
+ }
343
405
  return;
344
406
  }
345
407
 
346
408
  const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
347
409
  const styled = decorations.shimmer
348
- ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed)
410
+ ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
349
411
  : ctx.ui.theme.fg("text", word);
350
412
  const meter = decorations.tokenActivityMonitor
351
413
  ? state.activityMeter.render((level, char) =>
@@ -358,19 +420,46 @@ export class SessionManager {
358
420
  ),
359
421
  )
360
422
  : "";
361
- ctx.ui.setWorkingMessage(
362
- buildWorkingMessage(
363
- ctx.ui.theme,
364
- {
365
- spinner,
366
- text: styled,
367
- meter,
368
- elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
369
- tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
370
- },
371
- this.#settings.loaderOrder,
372
- ),
423
+ let tokenRateText = "";
424
+ if (features.tokenRate) {
425
+ const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
426
+ if (latestTokenRate && hasNewTokenCount) {
427
+ state.tokenRateText = latestTokenRate;
428
+ state.tokenRateFadeStartsAt = now + TOKEN_RATE_HOLD_MS;
429
+ } else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
430
+ state.tokenRateText = "";
431
+ }
432
+ tokenRateText = state.tokenRateText;
433
+ }
434
+ const tokenRateSegment = !features.tokenRate
435
+ ? ""
436
+ : !tokenRateText
437
+ ? ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER)
438
+ : now < state.tokenRateFadeStartsAt
439
+ ? ctx.ui.theme.fg(decorations.tokenRateColor, tokenRateText)
440
+ : fadeThemeColorString(
441
+ tokenRateText,
442
+ Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
443
+ ctx.ui.theme,
444
+ decorations.tokenRateColor,
445
+ );
446
+ const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
447
+ const msg = buildWorkingMessage(
448
+ ctx.ui.theme,
449
+ {
450
+ spinner,
451
+ text: styled,
452
+ meter,
453
+ elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
454
+ tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
455
+ tokenRate: tokenRateStyled,
456
+ },
457
+ this.#settings.loaderOrder,
373
458
  );
459
+ if (msg !== state.lastMessage) {
460
+ state.lastMessage = msg;
461
+ ctx.ui.setWorkingMessage(msg);
462
+ }
374
463
  }
375
464
 
376
465
  private async showSettings(ctx: ExtensionCommandContext): Promise<void> {
package/src/settings.ts CHANGED
@@ -1,13 +1,34 @@
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
4
  import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
5
5
  import type { MenuSection } from "./menu.ts";
6
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
+
7
27
  export interface DecoratorSettings {
8
28
  decorations: {
9
29
  animatedSpinner: boolean;
10
30
  shimmer: boolean;
31
+ shimmerInverted: boolean;
11
32
  shimmerDirection: "ltr" | "rtl";
12
33
  shimmerDirectionEnabled: boolean;
13
34
  shimmerSpeed: "slow" | "normal" | "fast";
@@ -16,23 +37,30 @@ export interface DecoratorSettings {
16
37
  meterDirection: "ltr" | "rtl";
17
38
  meterDirectionEnabled: boolean;
18
39
  decorateUserPrompt: boolean;
19
- borderColor: "accent" | "border" | "borderAccent";
40
+ borderColor: SettingColor;
20
41
  borderColorEnabled: boolean;
21
- borderStyle: "double" | "single" | "rounded" | "heavy";
42
+ borderStyle: BorderStyle;
22
43
  borderStyleEnabled: boolean;
23
- spinnerColor: "accent" | "border" | "borderAccent";
44
+ doneMarkerBorderStyle: DoneMarkerBorderStyle;
45
+ doneMarkerBorderColor: SettingColor;
46
+ spinnerColor: SettingColor;
24
47
  spinnerColorEnabled: boolean;
25
- meterColor: "accent" | "border" | "borderAccent";
48
+ meterColor: SettingColor;
26
49
  meterColorEnabled: boolean;
27
50
  meterDimmed: boolean;
51
+ tokenRateColor: SettingColor;
52
+ tokenRateDimmed: boolean;
28
53
  promptIcon: boolean;
29
54
  promptTimestamp: boolean;
55
+ promptProvider: boolean;
56
+ promptModel: boolean;
30
57
  useNerdFont: boolean;
31
58
  };
32
59
  features: {
33
60
  substituteDefaultMessage: boolean;
34
61
  elapsedTime: boolean;
35
62
  outputTokens: boolean;
63
+ tokenRate: boolean;
36
64
  doneMarker: boolean;
37
65
  doneMarkerIcon: boolean;
38
66
  randomizeDoneMarker: boolean;
@@ -43,8 +71,8 @@ export interface DecoratorSettings {
43
71
  }
44
72
 
45
73
  export const DEFAULT_SETTINGS: DecoratorSettings = {
46
- decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "ltr", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "accent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, promptIcon: true, promptTimestamp: true, useNerdFont: true },
47
- features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: 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 },
48
76
  loaderOrder: [...DEFAULT_LOADER_ORDER],
49
77
  };
50
78
 
@@ -56,6 +84,7 @@ const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
56
84
  meter: "Token activity monitor",
57
85
  elapsed: "Elapsed time",
58
86
  tokens: "Output tokens",
87
+ tokenRate: "Token rate",
59
88
  };
60
89
 
61
90
  /**
@@ -80,12 +109,15 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
80
109
  const merged = { ...defaults };
81
110
  if (!isPlainObject(parsed)) return merged;
82
111
  for (const [key, value] of Object.entries(parsed)) {
83
- if (!(key in merged)) continue;
84
- if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
85
- if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) (merged as Record<string, boolean | string>)[key] = value;
86
- if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) (merged as Record<string, boolean | string>)[key] = value;
87
- if (key.endsWith("Color") && (value === "accent" || value === "border" || value === "borderAccent")) (merged as Record<string, boolean | string>)[key] = value;
88
- if (key === "borderStyle" && (value === "double" || value === "single" || value === "rounded" || value === "heavy")) (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;
89
121
  }
90
122
  return merged;
91
123
  }
@@ -110,6 +142,18 @@ function fromCycleValue(value: string): string {
110
142
  return value;
111
143
  }
112
144
 
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
+
113
157
  type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
114
158
  type DecorationSettings = DecoratorSettings["decorations"];
115
159
  type FeatureSettings = DecoratorSettings["features"];
@@ -120,23 +164,32 @@ type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureS
120
164
  type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
121
165
  export const MENU_ENTRIES: readonly MenuEntry[] = [
122
166
  { id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" },
123
- { id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "border" },
124
- { id: "borderStyle", label: "Border style", section: "User Prompt", group: "decorations", key: "borderStyle", cycleValues: ["double", "single", "rounded", "heavy"], cycleEnabledBy: "borderStyleEnabled", cycleDisabledValue: "double" },
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" },
125
169
  { id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
126
170
  { id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" },
171
+ { id: "promptProvider", label: "Provider", section: "User Prompt", group: "decorations", key: "promptProvider" },
172
+ { id: "promptModel", label: "Model", section: "User Prompt", group: "decorations", key: "promptModel" },
127
173
  { id: "animatedSpinner", label: "Animated spinner", section: "“Working” Loader", group: "decorations", key: "animatedSpinner" },
128
- { id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
174
+ { id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
129
175
  { id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "“Working” Loader", group: "features", key: "substituteDefaultMessage" },
130
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" },
131
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" },
132
179
  { id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["Slow", "Normal", "Fast"], cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "Normal" },
133
180
  { id: "tokenActivityMonitor", label: "Token activity monitor", section: "“Working” Loader", group: "decorations", key: "tokenActivityMonitor" },
134
- { id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
135
- { id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Left to Right" },
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" },
136
183
  { id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
137
184
  { id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
138
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" },
139
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 },
140
193
  { id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
141
194
  { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
142
195
  { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
@@ -162,10 +215,15 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
162
215
  case "borderColor":
163
216
  case "spinnerColor":
164
217
  case "meterColor":
165
- if (stored === "accent" || stored === "border" || stored === "borderAccent") decorations[key] = stored;
218
+ case "tokenRateColor":
219
+ case "doneMarkerBorderColor":
220
+ if (isSettingColor(stored)) decorations[key] = stored;
166
221
  return;
167
222
  case "borderStyle":
168
- if (stored === "double" || stored === "single" || stored === "rounded" || stored === "heavy") decorations[key] = stored;
223
+ if (isBorderStyle(stored)) decorations[key] = stored;
224
+ return;
225
+ case "doneMarkerBorderStyle":
226
+ if (isDoneMarkerBorderStyle(stored)) decorations[key] = stored;
169
227
  return;
170
228
  case "shimmerDirection":
171
229
  case "meterDirection":
@@ -180,17 +238,17 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
180
238
  }
181
239
  }
182
240
 
183
- function toggleSection(title: MenuSectionName, settings: DecoratorSettings): MenuSection {
241
+ function buildSection(title: MenuSectionName, settings: DecoratorSettings): MenuSection {
184
242
  return { title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) };
185
243
  }
186
244
 
187
245
  export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
188
246
  return [
189
- toggleSection("User Prompt", settings),
190
- toggleSection("“Working” Loader", settings),
247
+ buildSection("User Prompt", settings),
248
+ buildSection("“Working” Loader", settings),
191
249
  { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
192
- toggleSection("Completion Marker", settings),
193
- toggleSection("Options", settings),
250
+ buildSection("Completion Marker", settings),
251
+ buildSection("Options", settings),
194
252
  ];
195
253
  }
196
254
 
@@ -212,27 +270,35 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
212
270
  return next;
213
271
  }
214
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
+ */
215
279
  export function loadSettings(): DecoratorSettings {
216
280
  try {
217
281
  const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
218
282
  if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
219
283
  const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder) };
220
- const gated: readonly (readonly ["borderColor" | "borderStyle" | "spinnerColor" | "meterColor" | "shimmerDirection" | "meterDirection" | "shimmerSpeed", "borderColorEnabled" | "borderStyleEnabled" | "spinnerColorEnabled" | "meterColorEnabled" | "shimmerDirectionEnabled" | "meterDirectionEnabled" | "shimmerSpeedEnabled", string])[] = [
221
- ["borderColor", "borderColorEnabled", "border"],
222
- ["borderStyle", "borderStyleEnabled", "double"],
223
- ["spinnerColor", "spinnerColorEnabled", "accent"],
224
- ["meterColor", "meterColorEnabled", "accent"],
225
- ["shimmerDirection", "shimmerDirectionEnabled", "ltr"],
226
- ["meterDirection", "meterDirectionEnabled", "ltr"],
227
- ["shimmerSpeed", "shimmerSpeedEnabled", "normal"],
228
- ];
229
- for (const [key, enabledKey, resetValue] of gated) {
230
- if (!settings.decorations[enabledKey]) (settings.decorations as unknown as Record<string, string>)[key] = resetValue;
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
+ }
231
288
  }
232
289
  return settings;
233
290
  } catch { return structuredClone(DEFAULT_SETTINGS); }
234
291
  }
292
+
235
293
  export function saveSettings(settings: DecoratorSettings): void {
236
- const path = settingsPath(); mkdirSync(dirname(path), { recursive: true });
237
- 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
+ }
238
304
  }