@underactive/pi-topping 0.4.0 → 0.5.1

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,15 +145,16 @@ 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
  },
122
156
  },
123
- {
124
- triggerTurn: true,
125
- ...(event.streamingBehavior ? { deliverAs: event.streamingBehavior } : {}),
126
- },
157
+ { triggerTurn: true },
127
158
  );
128
159
  return { action: "handled" };
129
160
  }
@@ -165,8 +196,11 @@ export class SessionManager {
165
196
  const exactTokens = event.message.usage?.output;
166
197
  this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
167
198
  if (exactTokens !== undefined) {
199
+ // Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
200
+ // those must keep holding/fading across message boundaries to avoid flicker.
168
201
  this.#state.rateTracker.reset();
169
- this.#state.lastActivityMeterUpdate = 0;
202
+ this.#state.lastTokenRateSampledAt = 0;
203
+ this.#state.lastTokenRateTotal = this.#state.confirmTokens;
170
204
  }
171
205
  this.#state.liveTokens = 0;
172
206
  this.#counter.reset();
@@ -194,8 +228,8 @@ export class SessionManager {
194
228
  ctx.ui.setWorkingMessage();
195
229
  this.applyIndicator(ctx);
196
230
  this.#state.activityMeter.reset();
197
- this.#state.rateTracker.reset();
198
- this.#state.lastActivityMeterUpdate = 0;
231
+ resetTokenRateState(this.#state);
232
+ this.#state.lastMessage = NOT_SENT;
199
233
  if (this.#settings.features.doneMarker && hadPrompt) {
200
234
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
201
235
  word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
@@ -211,6 +245,39 @@ export class SessionManager {
211
245
  this.stopTimer();
212
246
  };
213
247
 
248
+ #renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
249
+ if (!entry.data) return undefined;
250
+ const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
251
+ const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
252
+ const features = this.#settings.features;
253
+ const details: string[] = [];
254
+ if (features.doneMarkerTokens && typeof entry.data.tokens === "number") details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
255
+ if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && entry.data.midTurnInputs) {
256
+ details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
257
+ }
258
+ const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
259
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
260
+ const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
261
+ const borderColor = this.#settings.decorations.doneMarkerBorderColor;
262
+ if (borderStyle === "none") return new Text(markerContent);
263
+
264
+ let cachedWidth: number | undefined;
265
+ let cachedLines: string[] | undefined;
266
+ return {
267
+ render(width: number): string[] {
268
+ if (cachedWidth !== width) {
269
+ cachedWidth = width;
270
+ cachedLines = ["", ` ${buildCompletionMarkerLine(markerContent, width - 2, theme, borderStyle, borderColor)}`, ""];
271
+ }
272
+ return cachedLines!;
273
+ },
274
+ invalidate(): void {
275
+ cachedWidth = undefined;
276
+ cachedLines = undefined;
277
+ },
278
+ } satisfies Component;
279
+ }
280
+
214
281
  install(): void {
215
282
  this.#pi.on("session_start", this.#onSessionStart);
216
283
  this.#pi.on("input", this.#onInput);
@@ -221,22 +288,10 @@ export class SessionManager {
221
288
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
222
289
  this.#pi.on("agent_settled", this.#onAgentSettled);
223
290
  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
- });
291
+ this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
237
292
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
238
293
  this.#pi.registerCommand("topping-settings", {
239
- description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
294
+ description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
240
295
  handler: async (_a, ctx) => this.showSettings(ctx),
241
296
  });
242
297
  }
@@ -245,8 +300,19 @@ export class SessionManager {
245
300
  return !!ctx.hasUI && ctx.mode !== "print";
246
301
  }
247
302
 
303
+ /**
304
+ * Mid-stream inputs (steer/follow-up) must pass through untouched: re-sending them via
305
+ * sendMessage(deliverAs) bypasses Pi's queue state, so the "Steering:"/"Follow-up:" rows
306
+ * below the editor never render (issue #2).
307
+ */
248
308
  private shouldDecorate(event: InputEvent): boolean {
249
- return event.source !== "extension" && !!event.text.trim() && !event.images?.length && !/^[\/!?:]/.test(event.text);
309
+ return (
310
+ !event.streamingBehavior &&
311
+ event.source !== "extension" &&
312
+ !!event.text.trim() &&
313
+ !event.images?.length &&
314
+ !/^[\/!?:]/.test(event.text)
315
+ );
250
316
  }
251
317
 
252
318
  /**
@@ -257,7 +323,7 @@ export class SessionManager {
257
323
  return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
258
324
  }
259
325
 
260
- private spinnerColor(): "accent" | "border" | "borderAccent" {
326
+ private spinnerColor(): SettingColor {
261
327
  const decorations = this.#settings.decorations;
262
328
  return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
263
329
  }
@@ -293,13 +359,10 @@ export class SessionManager {
293
359
 
294
360
  const features = this.#settings.features;
295
361
  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;
362
+ let interval: number | undefined;
363
+ if (decorations.shimmer) interval = SHIMMER_INTERVAL_MS;
364
+ else if (decorations.tokenActivityMonitor || features.outputTokens || features.tokenRate) interval = METER_INTERVAL_MS;
365
+ else if (features.elapsedTime) interval = ELAPSED_INTERVAL_MS;
303
366
  if (this.spinnerInMessage()) interval = Math.min(interval ?? SPINNER_FRAME_MS, SPINNER_FRAME_MS);
304
367
  if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
305
368
  }
@@ -312,9 +375,9 @@ export class SessionManager {
312
375
  state.confirmTokens = 0;
313
376
  state.liveTokens = 0;
314
377
  state.activityMeter.reset();
315
- state.rateTracker.reset();
316
- state.lastActivityMeterUpdate = 0;
378
+ resetTokenRateState(state);
317
379
  state.midTurnInputs = 0;
380
+ state.lastMessage = NOT_SENT;
318
381
  this.#counter.reset();
319
382
  }
320
383
 
@@ -327,25 +390,32 @@ export class SessionManager {
327
390
  const total = state.confirmTokens + state.liveTokens;
328
391
  const features = this.#settings.features;
329
392
  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;
393
+ let hasNewTokenCount = false;
394
+ if ((decorations.tokenActivityMonitor || features.tokenRate) && now - state.lastTokenRateSampledAt >= METER_INTERVAL_MS) {
395
+ // Do not reset the fade for an EMA-only decay while output is quiet.
396
+ hasNewTokenCount = total > state.lastTokenRateTotal;
397
+ state.lastTokenRateTotal = total;
398
+ const tokenRate = state.rateTracker.sample(total, now);
399
+ if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
400
+ state.lastTokenRateSampledAt = now;
333
401
  }
334
402
  const spinner = this.spinnerInMessage()
335
403
  ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
336
404
  : "";
337
405
  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
- );
406
+ const msg = spinner
407
+ ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
408
+ : undefined;
409
+ if (msg !== state.lastMessage) {
410
+ state.lastMessage = msg;
411
+ ctx.ui.setWorkingMessage(msg);
412
+ }
343
413
  return;
344
414
  }
345
415
 
346
416
  const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
347
417
  const styled = decorations.shimmer
348
- ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed)
418
+ ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
349
419
  : ctx.ui.theme.fg("text", word);
350
420
  const meter = decorations.tokenActivityMonitor
351
421
  ? state.activityMeter.render((level, char) =>
@@ -358,19 +428,46 @@ export class SessionManager {
358
428
  ),
359
429
  )
360
430
  : "";
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
- ),
431
+ let tokenRateText = "";
432
+ if (features.tokenRate) {
433
+ const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
434
+ if (latestTokenRate && hasNewTokenCount) {
435
+ state.tokenRateText = latestTokenRate;
436
+ state.tokenRateFadeStartsAt = now + TOKEN_RATE_HOLD_MS;
437
+ } else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
438
+ state.tokenRateText = "";
439
+ }
440
+ tokenRateText = state.tokenRateText;
441
+ }
442
+ const tokenRateSegment = !features.tokenRate
443
+ ? ""
444
+ : !tokenRateText
445
+ ? ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER)
446
+ : now < state.tokenRateFadeStartsAt
447
+ ? ctx.ui.theme.fg(decorations.tokenRateColor, tokenRateText)
448
+ : fadeThemeColorString(
449
+ tokenRateText,
450
+ Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
451
+ ctx.ui.theme,
452
+ decorations.tokenRateColor,
453
+ );
454
+ const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
455
+ const msg = buildWorkingMessage(
456
+ ctx.ui.theme,
457
+ {
458
+ spinner,
459
+ text: styled,
460
+ meter,
461
+ elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
462
+ tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
463
+ tokenRate: tokenRateStyled,
464
+ },
465
+ this.#settings.loaderOrder,
373
466
  );
467
+ if (msg !== state.lastMessage) {
468
+ state.lastMessage = msg;
469
+ ctx.ui.setWorkingMessage(msg);
470
+ }
374
471
  }
375
472
 
376
473
  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
  }