@underactive/pi-topping 0.6.5 → 0.7.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/README.md CHANGED
@@ -31,12 +31,12 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
31
31
  ╔═[ Pi Topping: Settings ]══════════════════════════════════════╗
32
32
  ╟─ Preview ─────────────────────────────────────────────────────╢
33
33
  ║ ║
34
- ║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
34
+ ║ ⠋ Crafting ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
35
35
  ║ ║
36
36
  ╟─ User Prompt ─────────────────────────────────────────────────╢
37
37
  ║ [■] High-vis prompt ON ║
38
38
  ║ [■] Border style ‹ double › ║
39
- ║ [■] Border color ‹ borderAccent › ║
39
+ ║ [■] Border color ‹ thinkingLevel › ║
40
40
  ║ [■] Pi icon ON ║
41
41
  ║ [■] Timestamp ON ║
42
42
  ║ [■] Provider ON ║
@@ -44,7 +44,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
44
44
  ║ ║
45
45
  ╟─ “Working” Loader ────────────────────────────────────────────╢
46
46
  ║ [■] Animated spinner ON ║
47
- ║ ❯ [■] Animated spinner color ‹ accent › ║
47
+ ║ ❯ [■] Animated spinner color ‹ thinkingLevel › ║
48
48
  ║ [■] Randomize “Working” text ON ║
49
49
  ║ [■] Text shimmer ON ║
50
50
  ║ [■] Invert shimmer OFF ║
@@ -76,7 +76,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
76
76
  ║ [■] Show completion marker ON ║
77
77
  ║ [■] Marker style ‹ elite › ║
78
78
  ║ [■] Border style ‹ none › ║
79
- ║ [■] Border color ‹ borderAccent › ║
79
+ ║ [■] Border color ‹ thinkingLevel › ║
80
80
  ║ [■] Pi icon ON ║
81
81
  ║ [■] Randomize “Worked” text ON ║
82
82
  ║ [■] Tokens spent ON ║
@@ -119,6 +119,8 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
119
119
 
120
120
  Pack IDs must start with a lowercase letter and contain only lowercase letters, digits, and `-`. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
121
121
 
122
+ The `default` animated spinner and completion marker border colors defer to Pi's thinking-level color. The `thinking-level` prompt border color follows the level captured when the prompt is submitted. The `thinkingLevel` color option for the token activity monitor, token rate, and response model follows Pi's active thinking-level color.
123
+
122
124
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
123
125
  left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
124
126
  `·` separators whenever adjacent. When reordering separates them, each adjacent run is rendered
@@ -136,6 +138,7 @@ The response model is captured from assistant responses, displayed as its saniti
136
138
  ## Requirements
137
139
 
138
140
  - [Pi](https://github.com/earendil-works/pi-coding-agent). Pi ≥0.84.4 is required for the waiting-for-input loader state; older Pi versions keep the animated loader.
141
+ - On Pi ≥0.85.0, the working indicator is embedded in the default editor border and long loaders are truncated to the border width instead of wrapping; custom editors that do not opt in keep the standalone wrapping indicator.
139
142
  - Node.js ≥22.19.0
140
143
 
141
144
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -123,16 +123,16 @@ export class ActivityMeter {
123
123
  this.#levels.fill(0);
124
124
  }
125
125
 
126
- /** Colorize a meter cell using the default theme mapping (dim at IDLE, `color` otherwise). */
126
+ /** Colorize a meter cell (dim at IDLE, `colorize` otherwise). */
127
127
  static colorizeCell(
128
128
  level: ActivityMeterLevel,
129
129
  char: string,
130
130
  theme: { fg: (style: ThemeColor, s: string) => string },
131
- color: ThemeColor = "accent",
131
+ colorize: (text: string) => string,
132
132
  dimmed?: boolean,
133
133
  ): string {
134
134
  if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
135
- const colored = theme.fg(color, char);
135
+ const colored = colorize(char);
136
136
  return dimmed ? dimAttribute(colored) : colored;
137
137
  }
138
138
  }
package/src/format.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { ThinkingLevelColor, ThinkingLevelSettingColor } from "./settings.ts";
2
3
 
3
4
  /** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
4
5
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -6,8 +7,27 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
6
7
  /** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
7
8
  export const SPINNER_FRAME_MS = 80;
8
9
 
9
- /** Default "Working…" text shown when text randomization/substitution is off. */
10
- export const DEFAULT_WORKING_WORD = "Working…";
10
+ /** Default "Working" text shown when text randomization/substitution is off. */
11
+ export const DEFAULT_WORKING_WORD = "Working";
12
+
13
+ export type ThinkingLevel = Parameters<Theme["getThinkingBorderColor"]>[0];
14
+
15
+ export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
16
+
17
+ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
18
+ return typeof value === "string" && THINKING_LEVEL_VALUES.some(level => level === value);
19
+ }
20
+
21
+ /** Build a colorizer for a configured color, including Pi's active thinking-level color. */
22
+ export function getThinkingLevelColorizer(
23
+ theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
24
+ color: ThinkingLevelColor | ThinkingLevelSettingColor,
25
+ thinkingLevel: ThinkingLevel | undefined,
26
+ ): (text: string) => string {
27
+ return color === "thinking-level"
28
+ ? theme.getThinkingBorderColor(thinkingLevel ?? "off")
29
+ : (text: string) => theme.fg(color, text);
30
+ }
11
31
 
12
32
  /** Milliseconds between activity meter sample pushes. */
13
33
  export const METER_INTERVAL_MS = 100;
@@ -24,7 +44,7 @@ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens"
24
44
  /** Default first-use working-indicator element order. */
25
45
  export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens", "responseModel"];
26
46
 
27
- /** Elements share a detail group; separators and non-rate details are dimmed. */
47
+ /** Elements share a detail group; separators are dimmed and non-rate details use muted. */
28
48
  const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate", "responseModel"]);
29
49
 
30
50
  const TOKEN_UNITS = [
@@ -84,13 +104,13 @@ export const RESPONSE_MODEL_FADE_MS = 500;
84
104
  export function fadeThemeColorString(
85
105
  text: string,
86
106
  shade: number,
87
- theme: Pick<Theme, "getFgAnsi" | "fg">,
88
- color: ThemeColor,
107
+ theme: Pick<Theme, "getFgAnsi">,
108
+ colorizer: (text: string) => string,
89
109
  ): string {
90
110
  if (!text) return "";
91
- const source = ansiToRgb(theme.getFgAnsi(color));
111
+ const source = ansiToRgb(colorizer("x"));
92
112
  const dim = ansiToRgb(theme.getFgAnsi("dim"));
93
- if (!source || !dim) return theme.fg(color, text);
113
+ if (!source || !dim) return colorizer(text);
94
114
  const clampedShade = Math.max(0, Math.min(TOKEN_RATE_FADE_SHADE_COUNT - 1, Math.floor(shade)));
95
115
  const progress = (clampedShade + 1) / TOKEN_RATE_FADE_SHADE_COUNT;
96
116
  const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
@@ -151,7 +171,7 @@ export function buildWorkingMessage(
151
171
  if (!value) continue;
152
172
  if (DETAIL_ELEMENTS.has(element)) {
153
173
  // tokenRate and responseModel arrive pre-colored; dimming them here would replace their configured colors.
154
- details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("dim", value));
174
+ details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("muted", value));
155
175
  continue;
156
176
  }
157
177
  flushDetails();
@@ -220,7 +240,7 @@ export function shimmerString(
220
240
  }
221
241
 
222
242
  function ansiToRgb(ansi: string): [number, number, number] | null {
223
- const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
243
+ const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
224
244
  if (!match) return null;
225
245
  return [Number(match[1]), Number(match[2]), Number(match[3])];
226
246
  }
package/src/menu.ts CHANGED
@@ -28,6 +28,8 @@ export interface MenuItem {
28
28
  value: MenuValue;
29
29
  /** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
30
30
  cycleValues?: readonly string[];
31
+ /** Optional display labels for cycle values; the keys remain the published values. */
32
+ cycleValueLabels?: Readonly<Record<string, string>>;
31
33
  /** ID of the boolean value that gates cycling; space toggles it. */
32
34
  cycleEnabledBy?: string;
33
35
  /** Initial enabled state when `cycleEnabledBy` is set (default true). */
@@ -59,11 +61,9 @@ export interface MenuConfig {
59
61
  * since the menu opened. Lines may contain ANSI styling and are truncated/
60
62
  * padded to fit automatically.
61
63
  *
62
- * Return `string[]` for legacy interval-based animation (defaults to 50ms
63
- * refresh), or a `PreviewResult` to declare the next refresh interval.
64
64
  * Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
65
65
  */
66
- preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => string[] | PreviewResult;
66
+ preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult;
67
67
  /** Optional heading for the preview block. Defaults to `Preview`. */
68
68
  previewTitle?: string;
69
69
  /** Maximum overlay height in rows or as a percentage of the terminal height. */
@@ -88,12 +88,6 @@ function closeTruncatedHyperlink(text: string): string {
88
88
  }
89
89
 
90
90
  interface FlatItem {
91
- id: string;
92
- label: string;
93
- cycleValues?: readonly string[];
94
- cycleEnabledBy?: string;
95
- cycleDisabledValue?: string;
96
- reorderGroup?: string;
97
91
  item: MenuItem;
98
92
  sectionIndex: number;
99
93
  }
@@ -159,7 +153,7 @@ export class MenuComponent implements Component {
159
153
  this.flat = [];
160
154
  for (const [sectionIndex, section] of config.sections.entries()) {
161
155
  for (const item of section.items) {
162
- this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, reorderGroup: item.reorderGroup, item, sectionIndex });
156
+ this.flat.push({ item, sectionIndex });
163
157
  }
164
158
  }
165
159
  this.initialValues = { ...this.values };
@@ -219,7 +213,7 @@ export class MenuComponent implements Component {
219
213
  [Key.left]: () => this.cycleCurrentValue(-1),
220
214
  [Key.right]: () => this.cycleCurrentValue(1),
221
215
  [Key.space]: () => {
222
- const item = this.flat[this.cursor]!;
216
+ const item = this.flat[this.cursor]!.item;
223
217
  if (item.cycleValues && item.cycleEnabledBy) {
224
218
  this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
225
219
  if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
@@ -258,20 +252,20 @@ export class MenuComponent implements Component {
258
252
  */
259
253
  private moveGrabbedItem(delta: number): boolean {
260
254
  const grabbed = this.flat[this.cursor]!;
261
- const group = grabbed.reorderGroup;
262
- if (group === undefined || this.values[grabbed.id] !== true) return false;
255
+ const group = grabbed.item.reorderGroup;
256
+ if (group === undefined || this.values[grabbed.item.id] !== true) return false;
263
257
 
264
258
  let start = this.cursor;
265
- while (start > 0 && this.flat[start - 1]!.reorderGroup === group) start--;
259
+ while (start > 0 && this.flat[start - 1]!.item.reorderGroup === group) start--;
266
260
  let end = this.cursor;
267
- while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
261
+ while (end < this.flat.length - 1 && this.flat[end + 1]!.item.reorderGroup === group) end++;
268
262
 
269
263
  const target = Math.max(start, Math.min(end, this.cursor + delta));
270
264
  if (target !== this.cursor) {
271
265
  this.flat.splice(this.cursor, 1);
272
266
  this.flat.splice(target, 0, grabbed);
273
267
  this.cursor = target;
274
- this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
268
+ this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.item.id).join(",");
275
269
  this.invalidate();
276
270
  }
277
271
  return true;
@@ -292,7 +286,7 @@ export class MenuComponent implements Component {
292
286
  }
293
287
 
294
288
  private cycleCurrentValue(delta: number): void {
295
- const item = this.flat[this.cursor]!;
289
+ const item = this.flat[this.cursor]!.item;
296
290
  if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
297
291
  const current = item.cycleValues.indexOf(this.values[item.id] as string);
298
292
  const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
@@ -306,14 +300,9 @@ export class MenuComponent implements Component {
306
300
  const result = this.previewFn(
307
301
  this.values,
308
302
  this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
309
- this.flat[this.cursor]?.id,
303
+ this.flat[this.cursor]?.item.id,
310
304
  width,
311
305
  );
312
- if (Array.isArray(result)) {
313
- this.previewNextRefreshMs = 50;
314
- return result;
315
- }
316
-
317
306
  this.previewNextRefreshMs = result.nextRefreshInMs;
318
307
  return result.lines;
319
308
  }
@@ -382,12 +371,7 @@ export class MenuComponent implements Component {
382
371
  }
383
372
 
384
373
  /** Render the scrolling settings body while keeping the selected item in view. */
385
- private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
386
- if (allLines.length <= maxRows) {
387
- this.scrollStart = 0;
388
- this.pageItemCount = Math.max(1, this.flat.length);
389
- return allLines;
390
- }
374
+ private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
391
375
  if (maxRows <= 0) {
392
376
  this.pageItemCount = 1;
393
377
  return [];
@@ -452,15 +436,16 @@ export class MenuComponent implements Component {
452
436
  const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
453
437
  const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
454
438
  const footer = this.buildFooter(innerWidth);
455
- const naturalBody = this.buildToggleSections(innerWidth);
456
- const naturalHeight = header.length + naturalBody.length + footer.length;
457
- if (maxRows === undefined || naturalHeight <= maxRows) {
439
+ const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size;
440
+ const naturalBodyLength = this.flat.length + 2 * sectionsWithItems;
441
+ if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) {
442
+ const naturalBody = this.buildToggleSections(innerWidth);
458
443
  this.pageItemCount = Math.max(1, this.flat.length);
459
444
  return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
460
445
  }
461
446
 
462
447
  const bodyRows = Math.max(0, maxRows - header.length - footer.length);
463
- const body = this.buildResponsiveToggleSections(innerWidth, bodyRows, naturalBody);
448
+ const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
464
449
  return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
465
450
  }
466
451
 
@@ -557,7 +542,9 @@ export class MenuComponent implements Component {
557
542
 
558
543
  if (item.cycleValues) {
559
544
  const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
560
- const stateWord = `‹ ${value} ›`;
545
+ const rawValue = typeof value === "string" ? value : String(value);
546
+ const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue;
547
+ const stateWord = `‹ ${displayValue} ›`;
561
548
  const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
562
549
  const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
563
550
  const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
package/src/preview.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
3
  import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
4
- import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
4
+ import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
5
5
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
6
- import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isDoneMarkerStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
6
+ import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
7
7
  import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
8
- import { pickRandomWord } from "./words.ts";
9
8
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
10
9
  // flat 28 tps for the token readouts.
11
10
  const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
@@ -16,7 +15,6 @@ function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.
16
15
  /** Stateful, per-menu preview renderer that follows the active settings section. */
17
16
  export class PreviewRenderer {
18
17
  // Preserve constructor-time randomness so rendering never changes the preview phrase.
19
- readonly #word = pickRandomWord();
20
18
  readonly #poolFraction = Math.random();
21
19
  readonly #packs: readonly WordPack[];
22
20
  readonly #meter = new ActivityMeter();
@@ -54,17 +52,18 @@ export class PreviewRenderer {
54
52
  const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
55
53
  const decorations = { shimmer: values.shimmer !== false, shimmerInverted: values.shimmerInverted === true, tokenActivityMonitor: values.tokenActivityMonitor !== false };
56
54
  let spinnerColor = DEFAULT_SETTINGS.decorations.spinnerColor;
57
- if (values.spinnerColorEnabled !== false && isSettingColor(values.spinnerColor)) {
55
+ if (isSpinnerColor(values.spinnerColor)) {
58
56
  spinnerColor = values.spinnerColor;
59
57
  }
60
58
  let spinner = "";
61
59
  if (values.animatedSpinner !== false) {
62
- spinner = this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
60
+ spinner = getThinkingLevelColorizer(this.#ctx.ui.theme, spinnerColor, this.#ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
63
61
  }
64
62
  const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
65
63
  let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
66
- if (isSettingColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
67
- const responseModelColored = features.responseModel ? this.#ctx.ui.theme.fg(responseModelColor, "test-model") : "";
64
+ if (isThinkingLevelColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
65
+ const responseModelColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, responseModelColor, this.#ctx.thinkingLevel);
66
+ const responseModelColored = features.responseModel ? responseModelColorizer("test-model") : "";
68
67
  const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
69
68
  if (isFullyDefaultAppearance(features, decorations)) {
70
69
  return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, order);
@@ -74,8 +73,7 @@ export class PreviewRenderer {
74
73
  const packValues = this.packValues(values);
75
74
  const signature = JSON.stringify(Object.keys(packValues).sort().map(k => `${k}=${packValues[k]}`));
76
75
  if (signature !== this.#cachedPackSignature) {
77
- const enabledPacks = this.#packs.filter((pack) => isWordPackEnabled(pack.id, packValues));
78
- this.#cachedPackWord = enabledPacks.length ? selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text : this.#word;
76
+ this.#cachedPackWord = selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text;
79
77
  this.#cachedPackSignature = signature;
80
78
  }
81
79
  word = this.#cachedPackWord;
@@ -87,24 +85,26 @@ export class PreviewRenderer {
87
85
  styledWord = this.#ctx.ui.theme.fg("text", word);
88
86
  }
89
87
  let meterColor = DEFAULT_SETTINGS.decorations.meterColor;
90
- if (values.meterColorEnabled !== false && isSettingColor(values.meterColor)) {
88
+ if (isThinkingLevelColor(values.meterColor)) {
91
89
  meterColor = values.meterColor;
92
90
  }
93
91
  let meter = "";
94
92
  if (decorations.tokenActivityMonitor) {
95
- meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed === true));
93
+ const meterColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, meterColor, this.#ctx.thinkingLevel);
94
+ meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColorizer, values.meterDimmed === true));
96
95
  }
97
96
  let tokenRateText = "";
98
97
  if (features.tokenRate) {
99
98
  tokenRateText = formatTokenRate(TOKEN_RATE_PER_SEC);
100
99
  }
101
100
  let tokenRateColor = DEFAULT_SETTINGS.decorations.tokenRateColor;
102
- if (isSettingColor(values.tokenRateColor)) {
101
+ if (isThinkingLevelColor(values.tokenRateColor)) {
103
102
  tokenRateColor = values.tokenRateColor;
104
103
  }
105
104
  let tokenRateColored = "";
106
105
  if (tokenRateText) {
107
- tokenRateColored = this.#ctx.ui.theme.fg(tokenRateColor, tokenRateText);
106
+ const tokenRateColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, tokenRateColor, this.#ctx.thinkingLevel);
107
+ tokenRateColored = tokenRateColorizer(tokenRateText);
108
108
  }
109
109
  let tokenRate = tokenRateColored;
110
110
  if (tokenRateColored && values.tokenRateDimmed === true) {
@@ -157,8 +157,9 @@ export class PreviewRenderer {
157
157
  icon: values.useNerdFont ? "" : "π",
158
158
  provider: this.#ctx.model?.provider,
159
159
  model: this.#ctx.model?.id,
160
- borderColor: isSettingColor(borderColor) ? borderColor : DEFAULT_SETTINGS.decorations.borderColor,
160
+ borderColor: isPromptBorderColor(borderColor) ? borderColor : DEFAULT_SETTINGS.decorations.borderColor,
161
161
  borderStyle: isBorderStyle(borderStyle) ? borderStyle : "double",
162
+ thinkingLevel: this.#ctx.thinkingLevel,
162
163
  });
163
164
  return timestamp === undefined
164
165
  ? { lines }
@@ -174,9 +175,9 @@ export class PreviewRenderer {
174
175
  if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
175
176
  const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details);
176
177
  const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
177
- const borderColor = isSettingColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
178
+ const borderColor = isDoneMarkerBorderColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
178
179
  const markerStyle = isDoneMarkerStyle(values.doneMarkerStyle) ? values.doneMarkerStyle : DEFAULT_SETTINGS.decorations.doneMarkerStyle;
179
- const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle);
180
+ const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle, this.#ctx.thinkingLevel);
180
181
  return { lines: ["", marker, ""] };
181
182
  }
182
183
  }
@@ -1,6 +1,7 @@
1
- import type { MessageRenderer, ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { DEFAULT_SETTINGS, isBorderStyle, isSettingColor, type BorderStyle, type DoneMarkerBorderStyle, type DoneMarkerStyle, type SettingColor } from "./settings.ts";
3
+ import { getThinkingLevelColorizer, isThinkingLevel, type ThinkingLevel } from "./format.ts";
4
+ import { DEFAULT_SETTINGS, isBorderStyle, isPromptBorderColor, type BorderStyle, type DoneMarkerBorderColor, type DoneMarkerBorderStyle, type DoneMarkerStyle, type PromptBorderColor } from "./settings.ts";
4
5
  import { stripControlChars } from "./util.ts";
5
6
 
6
7
  export const PROMPT_BOX_TYPE = "pi-topping-prompt";
@@ -25,11 +26,15 @@ export interface PromptBoxDetails {
25
26
  icon?: string;
26
27
  provider?: string;
27
28
  model?: string;
28
- borderColor?: ThemeColor;
29
+ borderColor?: PromptBorderColor;
29
30
  borderStyle?: BorderStyle;
31
+ thinkingLevel?: ThinkingLevel;
30
32
  }
31
33
 
32
- type PromptTheme = { fg(color: string, text: string): string };
34
+ type PromptTheme = {
35
+ fg(color: string, text: string): string;
36
+ getThinkingBorderColor(level: ThinkingLevel): (text: string) => string;
37
+ };
33
38
 
34
39
  const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
35
40
 
@@ -51,15 +56,16 @@ export function buildCompletionMarkerLine(
51
56
  width: number,
52
57
  theme: PromptTheme,
53
58
  borderStyle: DoneMarkerBorderStyle,
54
- borderColor: SettingColor,
59
+ borderColor: DoneMarkerBorderColor,
55
60
  markerStyle: DoneMarkerStyle = DEFAULT_SETTINGS.decorations.doneMarkerStyle,
61
+ thinkingLevel?: ThinkingLevel,
56
62
  ): string {
57
63
  const safeWidth = Math.max(0, Math.floor(width));
58
64
  if (safeWidth === 0) return "";
59
65
  if (borderStyle === "none") return truncateToWidth(content, safeWidth);
60
66
 
61
67
  const g = BORDER_GLYPHS[borderStyle];
62
- const border = (text: string) => theme.fg(borderColor, text);
68
+ const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
63
69
  const prefix = `${g.bl}${g.h.repeat(2)} `;
64
70
 
65
71
  const suffix = markerStyle === "bookend"
@@ -81,8 +87,10 @@ export function buildPromptBoxLines(
81
87
 
82
88
  const borderStyle = isBorderStyle(options.borderStyle) ? options.borderStyle : "double";
83
89
  const g = BORDER_GLYPHS[borderStyle];
84
- const borderColor = isSettingColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
85
- const border = (text: string) => theme.fg(borderColor, text);
90
+ const borderColor = isPromptBorderColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
91
+ const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
92
+ const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
93
+ const iconColor = (text: string) => theme.fg("text", text);
86
94
  const label = (text: string) => theme.fg("customMessageLabel", text);
87
95
  const muted = (text: string) => theme.fg("dim", text);
88
96
  const bodyText = (text: string) => theme.fg("customMessageText", text);
@@ -113,7 +121,7 @@ export function buildPromptBoxLines(
113
121
  const titleLength = icon ? visibleWidth(`${g.h}${g.h} ${icon} `) : 0;
114
122
  const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
115
123
  const topFill = Math.max(0, innerWidth - titleLength - timeLength);
116
- const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${label(icon)}${border(" ")}` : "";
124
+ const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${iconColor(icon)}${border(" ")}` : "";
117
125
  const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : "";
118
126
  const topLine = border(g.tl) + iconSeg + border(g.h.repeat(topFill)) + timeSeg + border(g.tr);
119
127
  const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
@@ -138,16 +146,19 @@ export function buildPromptBoxLines(
138
146
  export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
139
147
  const content = typeof message.content === "string" ? message.content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "") : "";
140
148
  const details = (message.details ?? {}) as PromptBoxDetails;
141
- const linesByWidth = new Map<number, string[]>();
149
+ let cachedWidth: number | undefined;
150
+ let cachedLines: string[] | undefined;
142
151
  return {
143
152
  render(width: number): string[] {
144
- let lines = linesByWidth.get(width);
145
- if (!lines) {
146
- lines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
147
- linesByWidth.set(width, lines);
153
+ if (cachedWidth !== width) {
154
+ cachedWidth = width;
155
+ cachedLines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
148
156
  }
149
- return lines;
157
+ return cachedLines!;
158
+ },
159
+ invalidate() {
160
+ cachedWidth = undefined;
161
+ cachedLines = undefined;
150
162
  },
151
- invalidate() { linesByWidth.clear(); },
152
163
  };
153
164
  };
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.
@@ -321,6 +333,14 @@ export function loadSettings(): DecoratorSettings {
321
333
  wordPacks.simcity = true;
322
334
  }
323
335
  const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks };
336
+ const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
337
+ if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) {
338
+ settings.decorations.spinnerColor = "thinking-level";
339
+ if (settings.decorations.borderColor === "borderAccent") settings.decorations.borderColor = "thinking-level";
340
+ }
341
+ if (schemaVersion < DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION) {
342
+ settings.decorations.doneMarkerBorderColor = "thinking-level";
343
+ }
324
344
  for (const entry of MENU_ENTRIES) {
325
345
  if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
326
346
  setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
@@ -343,5 +363,5 @@ export function atomicWriteFile(path: string, contents: string): void {
343
363
  }
344
364
 
345
365
  export function saveSettings(settings: DecoratorSettings): void {
346
- atomicWriteFile(settingsPath(), `${JSON.stringify(settings, null, 2)}\n`);
366
+ atomicWriteFile(settingsPath(), `${JSON.stringify({ schemaVersion: SETTINGS_SCHEMA_VERSION, ...settings }, null, 2)}\n`);
347
367
  }
package/src/word-packs.ts CHANGED
@@ -112,7 +112,7 @@ export function selectWorkingTextSelection(enabled: Record<string, boolean>, pac
112
112
  const total = WORDS.length + enabledWords.length;
113
113
  const idx = Math.min(total - 1, Math.floor(fraction * total));
114
114
  const entry = idx < WORDS.length ? WORDS[idx] : enabledWords[idx - WORDS.length];
115
- return { text: `${entry.present_tense}…`, pastTense: entry.past_tense };
115
+ return { text: entry.present_tense, pastTense: entry.past_tense };
116
116
  }
117
117
 
118
118
  export function pickWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[]): WorkingTextSelection {
package/src/words.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Activity word list for the randomized "Working..." indicator.
2
+ * Activity word list for the randomized "Working" indicator.
3
3
  *
4
4
  * A random word is picked on prompt and on tool execution start
5
- * (see index.ts).
5
+ * (see SessionManager in src/session.ts).
6
6
  */
7
7
 
8
8
  export interface WordEntry {
@@ -10,14 +10,6 @@ export interface WordEntry {
10
10
  past_tense: string;
11
11
  }
12
12
 
13
- export function pickRawWord(): WordEntry {
14
- return WORDS[Math.floor(Math.random() * WORDS.length)]!;
15
- }
16
-
17
- export function pickRandomWord(): string {
18
- return `${pickRawWord().present_tense}…`;
19
- }
20
-
21
13
  export const WORDS: WordEntry[] = [
22
14
  { present_tense: "Accomplishing", past_tense: "Accomplished" },
23
15
  { present_tense: "Actioning", past_tense: "Actioned" },