@underactive/pi-topping 0.6.5 → 0.7.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
@@ -18,6 +18,8 @@ import {
18
18
  buildWorkingMessage,
19
19
  DEFAULT_WORKING_WORD,
20
20
  dimAttribute,
21
+ getThinkingLevelColorizer,
22
+ isThinkingLevel,
21
23
  ELAPSED_INTERVAL_MS,
22
24
  fadeThemeColorString,
23
25
  formatElapsed,
@@ -34,11 +36,12 @@ import {
34
36
  TOKEN_RATE_FADE_SHADE_COUNT,
35
37
  TOKEN_RATE_PLACEHOLDER,
36
38
  StreamingWordCounter,
39
+ type ThinkingLevel,
37
40
  } from "./format.ts";
38
41
  import { isSiblingSetupEnabled } from "./flags.ts";
39
42
  import { showMenu } from "./menu.ts";
40
43
  import { registerSetupCommand } from "./setup-command.ts";
41
- import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type SettingColor } from "./settings.ts";
44
+ import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
42
45
  import { notifyMissingToppingsOnce } from "./toppings.ts";
43
46
  import { PreviewRenderer } from "./preview.ts";
44
47
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
@@ -73,6 +76,7 @@ interface DoneEntryData {
73
76
  elapsedMs: number;
74
77
  tokens?: number;
75
78
  midTurnInputs?: number;
79
+ thinkingLevel?: ThinkingLevel;
76
80
  }
77
81
 
78
82
  interface SessionState {
@@ -199,6 +203,7 @@ export class SessionManager {
199
203
  model: ctx.model?.id,
200
204
  borderColor: this.#settings.decorations.borderColor,
201
205
  borderStyle: this.#settings.decorations.borderStyle,
206
+ thinkingLevel: ctx.thinkingLevel,
202
207
  },
203
208
  },
204
209
  { triggerTurn: true },
@@ -217,6 +222,7 @@ export class SessionManager {
217
222
  if (!this.usable(ctx)) return;
218
223
  if (!this.#state.startTime) this.resetTurn(Date.now());
219
224
  this.#state.busy = true;
225
+ if (!this.#state.waiting) this.applyIndicator(ctx);
220
226
  this.startTimer();
221
227
  this.tick();
222
228
  };
@@ -232,19 +238,17 @@ export class SessionManager {
232
238
 
233
239
  #onMessageUpdate = async (event: MessageUpdateEvent, ctx: ExtensionContext): Promise<void> => {
234
240
  this.#currentCtx = ctx;
241
+ if (!this.usable(ctx)) return;
235
242
  const assistantEvent = event.assistantMessageEvent;
236
243
  if (
237
- this.usable(ctx) &&
238
244
  assistantEvent &&
239
245
  (assistantEvent.type === "text_delta" || assistantEvent.type === "thinking_delta")
240
246
  ) {
241
247
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
242
248
  }
243
- if (this.usable(ctx)) {
244
- const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
245
- ?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
246
- this.updateResponseModel(rawResponseModel);
247
- }
249
+ const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
250
+ ?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
251
+ this.updateResponseModel(rawResponseModel);
248
252
  };
249
253
 
250
254
  #onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
@@ -319,6 +323,7 @@ export class SessionManager {
319
323
  elapsedMs,
320
324
  tokens: this.#state.confirmTokens,
321
325
  midTurnInputs: this.#state.midTurnInputs,
326
+ thinkingLevel: ctx.thinkingLevel,
322
327
  });
323
328
  }
324
329
  this.#currentCtx = null;
@@ -336,8 +341,8 @@ export class SessionManager {
336
341
  const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
337
342
  const features = this.#settings.features;
338
343
  const details: string[] = [];
339
- if (features.doneMarkerTokens && typeof entry.data.tokens === "number") details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
340
- if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && entry.data.midTurnInputs) {
344
+ if (features.doneMarkerTokens && typeof entry.data.tokens === "number" && Number.isFinite(entry.data.tokens) && entry.data.tokens >= 0) details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
345
+ if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && Number.isInteger(entry.data.midTurnInputs) && entry.data.midTurnInputs > 0) {
341
346
  details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
342
347
  }
343
348
  const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
@@ -345,6 +350,7 @@ export class SessionManager {
345
350
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
346
351
  const borderColor = this.#settings.decorations.doneMarkerBorderColor;
347
352
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
353
+ const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
348
354
  if (borderStyle === "none") return new Text(markerContent);
349
355
 
350
356
  let cachedWidth: number | undefined;
@@ -353,7 +359,7 @@ export class SessionManager {
353
359
  render(width: number): string[] {
354
360
  if (cachedWidth !== width) {
355
361
  cachedWidth = width;
356
- cachedLines = ["", buildCompletionMarkerLine(markerContent, width, theme, borderStyle, borderColor, markerStyle), ""];
362
+ cachedLines = ["", buildCompletionMarkerLine(markerContent, width, theme, borderStyle, borderColor, markerStyle, thinkingLevel), ""];
357
363
  }
358
364
  return cachedLines!;
359
365
  },
@@ -412,11 +418,6 @@ export class SessionManager {
412
418
  return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
413
419
  }
414
420
 
415
- private spinnerColor(): SettingColor {
416
- const decorations = this.#settings.decorations;
417
- return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
418
- }
419
-
420
421
  private indicatorFingerprint(): string {
421
422
  const decorations = this.#settings.decorations;
422
423
  return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
@@ -435,12 +436,10 @@ export class SessionManager {
435
436
  return;
436
437
  }
437
438
 
438
- const color = this.spinnerColor();
439
- if (color === "accent") {
440
- ctx.ui.setWorkingIndicator(undefined);
441
- return;
442
- }
443
- ctx.ui.setWorkingIndicator({ frames: SPINNER_FRAMES.map((frame) => ctx.ui.theme.fg(color, frame)) });
439
+ const color = this.#settings.decorations.spinnerColor;
440
+ ctx.ui.setWorkingIndicator({
441
+ frames: SPINNER_FRAMES.map((frame) => getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel)(frame)),
442
+ });
444
443
  }
445
444
 
446
445
  private stopTimer(): void {
@@ -485,13 +484,19 @@ export class SessionManager {
485
484
  }
486
485
  }
487
486
 
488
- private startResponseModelFade(ctx: ExtensionContext, model: string, color: SettingColor, dimmed: boolean): void {
487
+ private startResponseModelFade(ctx: ExtensionContext, model: string, color: ThinkingLevelColor, dimmed: boolean): void {
489
488
  this.cancelResponseModelFade(ctx);
490
489
  const generation = this.#state.responseModelFadeGeneration;
490
+ const colorizer = getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel);
491
491
  const render = (shade?: number): void => {
492
492
  const colored = shade === undefined
493
- ? ctx.ui.theme.fg(color, model)
494
- : fadeThemeColorString(model, shade, ctx.ui.theme, color);
493
+ ? colorizer(model)
494
+ : fadeThemeColorString(
495
+ model,
496
+ shade,
497
+ ctx.ui.theme,
498
+ colorizer,
499
+ );
495
500
  const responseModel = dimmed ? dimAttribute(colored) : colored;
496
501
  ctx.ui.setStatus(RESPONSE_MODEL_STATUS_KEY, responseModel);
497
502
  };
@@ -573,10 +578,10 @@ export class SessionManager {
573
578
  state.lastTokenRateSampledAt = now;
574
579
  }
575
580
  const spinner = this.spinnerInMessage()
576
- ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
581
+ ? getThinkingLevelColorizer(ctx.ui.theme, decorations.spinnerColor, ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
577
582
  : "";
578
583
  const responseModelColored = features.responseModel && state.responseModel
579
- ? ctx.ui.theme.fg(decorations.responseModelColor, state.responseModel)
584
+ ? getThinkingLevelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel)(state.responseModel)
580
585
  : "";
581
586
  const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
582
587
  if (isFullyDefaultAppearance(features, decorations)) {
@@ -594,17 +599,13 @@ export class SessionManager {
594
599
  const styled = decorations.shimmer
595
600
  ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
596
601
  : ctx.ui.theme.fg("text", word);
597
- const meter = decorations.tokenActivityMonitor
598
- ? state.activityMeter.render((level, char) =>
599
- ActivityMeter.colorizeCell(
600
- level,
601
- char,
602
- ctx.ui.theme,
603
- decorations.meterColorEnabled ? decorations.meterColor : "accent",
604
- decorations.meterDimmed,
605
- ),
606
- )
607
- : "";
602
+ let meter = "";
603
+ if (decorations.tokenActivityMonitor) {
604
+ const meterColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.meterColor, ctx.thinkingLevel);
605
+ meter = state.activityMeter.render((level, char) =>
606
+ ActivityMeter.colorizeCell(level, char, ctx.ui.theme, meterColorizer, decorations.meterDimmed),
607
+ );
608
+ }
608
609
  let tokenRateText = "";
609
610
  if (features.tokenRate) {
610
611
  const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
@@ -616,18 +617,22 @@ export class SessionManager {
616
617
  }
617
618
  tokenRateText = state.tokenRateText;
618
619
  }
619
- const tokenRateSegment = !features.tokenRate
620
- ? ""
621
- : !tokenRateText
622
- ? ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER)
623
- : now < state.tokenRateFadeStartsAt
624
- ? ctx.ui.theme.fg(decorations.tokenRateColor, tokenRateText)
620
+ let tokenRateSegment = "";
621
+ if (features.tokenRate) {
622
+ if (!tokenRateText) {
623
+ tokenRateSegment = ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER);
624
+ } else {
625
+ const tokenRateColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.tokenRateColor, ctx.thinkingLevel);
626
+ tokenRateSegment = now < state.tokenRateFadeStartsAt
627
+ ? tokenRateColorizer(tokenRateText)
625
628
  : fadeThemeColorString(
626
629
  tokenRateText,
627
630
  Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
628
631
  ctx.ui.theme,
629
- decorations.tokenRateColor,
632
+ tokenRateColorizer,
630
633
  );
634
+ }
635
+ }
631
636
  const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
632
637
  const msg = buildWorkingMessage(
633
638
  ctx.ui.theme,
@@ -657,7 +662,7 @@ export class SessionManager {
657
662
  this.#userPacks = loadUserWordPacks();
658
663
  this.#allPacks = [...this.#bundledPacks, ...this.#userPacks];
659
664
  const before = this.indicatorFingerprint();
660
- const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
665
+ const preview = new PreviewRenderer(ctx, this.#allPacks);
661
666
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
662
667
  title: "Pi Topping: Settings",
663
668
  maxHeight: "75%",
package/src/settings.ts CHANGED
@@ -7,7 +7,20 @@ import { isWordPackEnabled, isWordPackId, type WordPack } from "./word-packs.ts"
7
7
  import { isPlainObject } from "./util.ts";
8
8
 
9
9
  export const SETTING_COLOR_VALUES = ["accent", "border", "borderAccent", "success", "error", "warning"] as const;
10
- export type SettingColor = (typeof SETTING_COLOR_VALUES)[number];
10
+ export const LOADER_COLOR_VALUES = [...SETTING_COLOR_VALUES, "text", "muted"] as const;
11
+ export const THINKING_LEVEL_COLOR_VALUES = ["thinking-level", ...LOADER_COLOR_VALUES] as const;
12
+ export type ThinkingLevelColor = (typeof THINKING_LEVEL_COLOR_VALUES)[number];
13
+ export const THINKING_LEVEL_SETTING_COLOR_VALUES = ["thinking-level", ...SETTING_COLOR_VALUES] as const;
14
+ export type ThinkingLevelSettingColor = (typeof THINKING_LEVEL_SETTING_COLOR_VALUES)[number];
15
+ export const SPINNER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALUES;
16
+ export type SpinnerColor = ThinkingLevelSettingColor;
17
+ export const PROMPT_BORDER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALUES;
18
+ export type PromptBorderColor = ThinkingLevelSettingColor;
19
+ export const DONE_MARKER_BORDER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALUES;
20
+ export type DoneMarkerBorderColor = ThinkingLevelSettingColor;
21
+ const THINKING_LEVEL_COLOR_MIGRATION_VERSION = 2;
22
+ const DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION = 3;
23
+ export const SETTINGS_SCHEMA_VERSION = 3;
11
24
 
12
25
  export const BORDER_STYLE_VALUES = ["double", "single", "rounded", "heavy"] as const;
13
26
  export type BorderStyle = (typeof BORDER_STYLE_VALUES)[number];
@@ -28,10 +41,18 @@ export function isDoneMarkerStyle(value: unknown): value is DoneMarkerStyle {
28
41
  return typeof value === "string" && DONE_MARKER_STYLE_VALUES.some(style => style === value);
29
42
  }
30
43
 
31
- export function isSettingColor(value: unknown): value is SettingColor {
32
- return typeof value === "string" && SETTING_COLOR_VALUES.some(color => color === value);
44
+ export function isThinkingLevelColor(value: unknown): value is ThinkingLevelColor {
45
+ return typeof value === "string" && THINKING_LEVEL_COLOR_VALUES.some(color => color === value);
33
46
  }
34
47
 
48
+ export function isThinkingLevelSettingColor(value: unknown): value is ThinkingLevelSettingColor {
49
+ return typeof value === "string" && THINKING_LEVEL_SETTING_COLOR_VALUES.some(color => color === value);
50
+ }
51
+
52
+ export const isSpinnerColor = isThinkingLevelSettingColor;
53
+ export const isPromptBorderColor = isThinkingLevelSettingColor;
54
+ export const isDoneMarkerBorderColor = isThinkingLevelSettingColor;
55
+
35
56
  export interface DecoratorSettings {
36
57
  decorations: {
37
58
  animatedSpinner: boolean;
@@ -45,21 +66,21 @@ export interface DecoratorSettings {
45
66
  meterDirection: "ltr" | "rtl";
46
67
  meterDirectionEnabled: boolean;
47
68
  decorateUserPrompt: boolean;
48
- borderColor: SettingColor;
69
+ borderColor: PromptBorderColor;
49
70
  borderColorEnabled: boolean;
50
71
  borderStyle: BorderStyle;
51
72
  borderStyleEnabled: boolean;
52
73
  doneMarkerBorderStyle: DoneMarkerBorderStyle;
53
- doneMarkerBorderColor: SettingColor;
74
+ doneMarkerBorderColor: DoneMarkerBorderColor;
54
75
  doneMarkerStyle: DoneMarkerStyle;
55
- spinnerColor: SettingColor;
76
+ spinnerColor: SpinnerColor;
56
77
  spinnerColorEnabled: boolean;
57
- meterColor: SettingColor;
78
+ meterColor: ThinkingLevelColor;
58
79
  meterColorEnabled: boolean;
59
80
  meterDimmed: boolean;
60
- tokenRateColor: SettingColor;
81
+ tokenRateColor: ThinkingLevelColor;
61
82
  tokenRateDimmed: boolean;
62
- responseModelColor: SettingColor;
83
+ responseModelColor: ThinkingLevelColor;
63
84
  responseModelDimmed: boolean;
64
85
  promptIcon: boolean;
65
86
  promptTimestamp: boolean;
@@ -84,7 +105,7 @@ export interface DecoratorSettings {
84
105
  }
85
106
 
86
107
  export const DEFAULT_SETTINGS: DecoratorSettings = {
87
- 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", doneMarkerStyle: "elite", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
108
+ decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "thinking-level", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "thinking-level", doneMarkerStyle: "elite", spinnerColor: "thinking-level", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
88
109
  features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
89
110
  loaderOrder: [...DEFAULT_LOADER_ORDER],
90
111
  wordPacks: {},
@@ -128,7 +149,12 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
128
149
  if (typeof merged[key] === "boolean" && typeof value === "boolean") valid = value;
129
150
  else if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) valid = value;
130
151
  else if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) valid = value;
131
- else if (key.endsWith("Color") && isSettingColor(value)) valid = value;
152
+ else if (key === "spinnerColor" && value === "default") valid = "thinking-level";
153
+ else if (key === "spinnerColor" && isSpinnerColor(value)) valid = value;
154
+ else if (key === "borderColor" && isPromptBorderColor(value)) valid = value;
155
+ else if (key === "doneMarkerBorderColor" && value === "default") valid = "thinking-level";
156
+ else if (key === "doneMarkerBorderColor" && isDoneMarkerBorderColor(value)) valid = value;
157
+ else if ((key === "meterColor" || key === "tokenRateColor" || key === "responseModelColor") && isThinkingLevelColor(value)) valid = value;
132
158
  else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
133
159
  else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
134
160
  else if (key === "doneMarkerStyle" && isDoneMarkerStyle(value)) valid = value;
@@ -137,36 +163,14 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
137
163
  return merged;
138
164
  }
139
165
 
140
- /** Map a persisted setting value to its menu cycle label (directions and speeds only; colors pass through). */
141
- function toCycleValue(stored: unknown): string {
142
- if (stored === "ltr") return "Left to Right";
143
- if (stored === "rtl") return "Right to Left";
144
- if (stored === "slow") return "Slow";
145
- if (stored === "normal") return "Normal";
146
- if (stored === "fast") return "Fast";
147
- return String(stored);
148
- }
149
-
150
- /** Map a menu cycle label back to its persisted value. */
151
- function fromCycleValue(value: string): string {
152
- if (value === "Left to Right") return "ltr";
153
- if (value === "Right to Left") return "rtl";
154
- if (value === "Slow") return "slow";
155
- if (value === "Normal") return "normal";
156
- if (value === "Fast") return "fast";
157
- return value;
158
- }
159
-
160
166
  /** Convert a persisted or menu direction to a safe preview direction. */
161
167
  export function fromCycleDirection(value: unknown): "ltr" | "rtl" {
162
- const stored = typeof value === "string" ? fromCycleValue(value) : "";
163
- return stored === "rtl" ? "rtl" : "ltr";
168
+ return value === "rtl" ? "rtl" : "ltr";
164
169
  }
165
170
 
166
171
  /** Convert a persisted or menu speed to a safe preview speed. */
167
172
  export function fromCycleSpeed(value: unknown): "slow" | "normal" | "fast" {
168
- const stored = typeof value === "string" ? fromCycleValue(value) : "";
169
- return stored === "slow" || stored === "fast" ? stored : "normal";
173
+ return value === "slow" || value === "fast" ? value : "normal";
170
174
  }
171
175
 
172
176
  type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
@@ -174,42 +178,45 @@ type DecorationSettings = DecoratorSettings["decorations"];
174
178
  type FeatureSettings = DecoratorSettings["features"];
175
179
  type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
176
180
  type MenuEntryBase = { id: string; label: string; section: MenuSectionName };
177
- type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string };
178
- type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleEnabledBy?: never; cycleDisabledValue?: never };
181
+ type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleValueLabels?: Readonly<Record<string, string>>; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string };
182
+ type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleValueLabels?: never; cycleEnabledBy?: never; cycleDisabledValue?: never };
179
183
  type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
184
+ const THINKING_LEVEL_CYCLE_LABELS = { "thinking-level": "thinkingLevel" } as const;
185
+ const DIRECTION_CYCLE_LABELS = { ltr: "Left to Right", rtl: "Right to Left" } as const;
186
+ const SPEED_CYCLE_LABELS = { slow: "Slow", normal: "Normal", fast: "Fast" } as const;
180
187
  export const MENU_ENTRIES: readonly MenuEntry[] = [
181
188
  { id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" },
182
189
  { id: "borderStyle", label: "Border style", section: "User Prompt", group: "decorations", key: "borderStyle", cycleValues: BORDER_STYLE_VALUES, cycleEnabledBy: "borderStyleEnabled", cycleDisabledValue: "double" },
183
- { id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "borderAccent" },
190
+ { id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: PROMPT_BORDER_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS, cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "thinking-level" },
184
191
  { id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
185
192
  { id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" },
186
193
  { id: "promptProvider", label: "Provider", section: "User Prompt", group: "decorations", key: "promptProvider" },
187
194
  { id: "promptModel", label: "Model", section: "User Prompt", group: "decorations", key: "promptModel" },
188
195
  { id: "animatedSpinner", label: "Animated spinner", section: "“Working” Loader", group: "decorations", key: "animatedSpinner" },
189
- { id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
196
+ { id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: SPINNER_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS, cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "thinking-level" },
190
197
  { id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "“Working” Loader", group: "features", key: "substituteDefaultMessage" },
191
198
  { id: "shimmer", label: "Text shimmer", section: "“Working” Loader", group: "decorations", key: "shimmer" },
192
199
  { id: "shimmerInverted", label: "Invert shimmer", section: "“Working” Loader", group: "decorations", key: "shimmerInverted" },
193
- { 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" },
194
- { id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["Slow", "Normal", "Fast"], cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "Normal" },
200
+ { id: "shimmerDirection", label: "Text shimmer direction", section: "“Working” Loader", group: "decorations", key: "shimmerDirection", cycleValues: ["ltr", "rtl"], cycleValueLabels: DIRECTION_CYCLE_LABELS, cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "ltr" },
201
+ { id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["slow", "normal", "fast"], cycleValueLabels: SPEED_CYCLE_LABELS, cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "normal" },
195
202
  { id: "tokenActivityMonitor", label: "Token activity monitor", section: "“Working” Loader", group: "decorations", key: "tokenActivityMonitor" },
196
- { id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
197
- { 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" },
203
+ { id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS, cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
204
+ { id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["ltr", "rtl"], cycleValueLabels: DIRECTION_CYCLE_LABELS, cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "rtl" },
198
205
  { id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
199
206
  { id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
200
207
  { id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" },
201
208
  // id differs from key: the Elements Order row already owns "tokenRate" in the menu's shared value namespace.
202
209
  { id: "showTokenRate", label: "Token rate", section: "“Working” Loader", group: "features", key: "tokenRate" },
203
- { id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: SETTING_COLOR_VALUES },
210
+ { id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
204
211
  { id: "tokenRateDimmed", label: "Token rate dimmed", section: "“Working” Loader", group: "decorations", key: "tokenRateDimmed" },
205
212
  // id differs from key: the Elements Order row already owns "responseModel" in the menu's shared value namespace.
206
213
  { id: "showResponseModel", label: "Response model", section: "“Working” Loader", group: "features", key: "responseModel" },
207
- { id: "responseModelColor", label: "Response model color", section: "“Working” Loader", group: "decorations", key: "responseModelColor", cycleValues: SETTING_COLOR_VALUES },
214
+ { id: "responseModelColor", label: "Response model color", section: "“Working” Loader", group: "decorations", key: "responseModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
208
215
  { id: "responseModelDimmed", label: "Response model dimmed", section: "“Working” Loader", group: "decorations", key: "responseModelDimmed" },
209
216
  { id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
210
217
  { id: "doneMarkerStyle", label: "Marker style", section: "Completion Marker", group: "decorations", key: "doneMarkerStyle", cycleValues: DONE_MARKER_STYLE_VALUES },
211
218
  { id: "doneMarkerBorderStyle", label: "Border style", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderStyle", cycleValues: DONE_MARKER_BORDER_STYLE_VALUES },
212
- { id: "doneMarkerBorderColor", label: "Border color", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderColor", cycleValues: SETTING_COLOR_VALUES },
219
+ { id: "doneMarkerBorderColor", label: "Border color", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderColor", cycleValues: DONE_MARKER_BORDER_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
213
220
  { id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
214
221
  { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
215
222
  { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
@@ -222,7 +229,7 @@ function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["i
222
229
  const cycleEnabled = entry.group === "decorations" && entry.cycleEnabledBy
223
230
  ? settings.decorations[entry.cycleEnabledBy]
224
231
  : undefined;
225
- return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, value: entry.cycleValues ? toCycleValue(value) : value };
232
+ return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleValueLabels: entry.cycleValueLabels, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, value };
226
233
  }
227
234
 
228
235
  function isDecorationBooleanKey(key: keyof DecorationSettings): key is DecorationBooleanKey {
@@ -230,31 +237,36 @@ function isDecorationBooleanKey(key: keyof DecorationSettings): key is Decoratio
230
237
  }
231
238
 
232
239
  function setDecorationCycleValue(decorations: DecorationSettings, key: keyof DecorationSettings, value: string): void {
233
- const stored = fromCycleValue(value);
234
240
  switch (key) {
235
241
  case "borderColor":
242
+ if (isPromptBorderColor(value)) decorations[key] = value;
243
+ return;
236
244
  case "spinnerColor":
245
+ if (isSpinnerColor(value)) decorations[key] = value;
246
+ return;
237
247
  case "meterColor":
238
248
  case "tokenRateColor":
239
249
  case "responseModelColor":
250
+ if (isThinkingLevelColor(value)) decorations[key] = value;
251
+ return;
240
252
  case "doneMarkerBorderColor":
241
- if (isSettingColor(stored)) decorations[key] = stored;
253
+ if (isDoneMarkerBorderColor(value)) decorations[key] = value;
242
254
  return;
243
255
  case "borderStyle":
244
- if (isBorderStyle(stored)) decorations[key] = stored;
256
+ if (isBorderStyle(value)) decorations[key] = value;
245
257
  return;
246
258
  case "doneMarkerBorderStyle":
247
- if (isDoneMarkerBorderStyle(stored)) decorations[key] = stored;
259
+ if (isDoneMarkerBorderStyle(value)) decorations[key] = value;
248
260
  return;
249
261
  case "doneMarkerStyle":
250
- if (isDoneMarkerStyle(stored)) decorations[key] = stored;
262
+ if (isDoneMarkerStyle(value)) decorations[key] = value;
251
263
  return;
252
264
  case "shimmerDirection":
253
265
  case "meterDirection":
254
- if (stored === "ltr" || stored === "rtl") decorations[key] = stored;
266
+ if (value === "ltr" || value === "rtl") decorations[key] = value;
255
267
  return;
256
268
  case "shimmerSpeed":
257
- if (stored === "slow" || stored === "normal" || stored === "fast") decorations[key] = stored;
269
+ if (value === "slow" || value === "normal" || value === "fast") decorations[key] = value;
258
270
  return;
259
271
  default:
260
272
  // Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here.
@@ -306,6 +318,10 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
306
318
  * to defaults on any read/parse error. When a cycle setting's enabled flag is
307
319
  * off, its stored value resets to the menu's disabled default so stale choices
308
320
  * don't resurface on re-enable.
321
+ * Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and
322
+ * `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker
323
+ * border color is reset. A legacy `features.simCityWorkingText: true` enables the
324
+ * `simcity` pack when no explicit preference is stored.
309
325
  */
310
326
  export function loadSettings(): DecoratorSettings {
311
327
  try {
@@ -321,6 +337,14 @@ export function loadSettings(): DecoratorSettings {
321
337
  wordPacks.simcity = true;
322
338
  }
323
339
  const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks };
340
+ const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
341
+ if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) {
342
+ settings.decorations.spinnerColor = "thinking-level";
343
+ if (settings.decorations.borderColor === "borderAccent") settings.decorations.borderColor = "thinking-level";
344
+ }
345
+ if (schemaVersion < DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION) {
346
+ settings.decorations.doneMarkerBorderColor = "thinking-level";
347
+ }
324
348
  for (const entry of MENU_ENTRIES) {
325
349
  if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
326
350
  setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
@@ -343,5 +367,5 @@ export function atomicWriteFile(path: string, contents: string): void {
343
367
  }
344
368
 
345
369
  export function saveSettings(settings: DecoratorSettings): void {
346
- atomicWriteFile(settingsPath(), `${JSON.stringify(settings, null, 2)}\n`);
370
+ atomicWriteFile(settingsPath(), `${JSON.stringify({ schemaVersion: SETTINGS_SCHEMA_VERSION, ...settings }, null, 2)}\n`);
347
371
  }