@underactive/pi-topping 0.8.1 → 0.9.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
@@ -6,7 +6,7 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
6
6
 
7
7
  ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_user_prompt.png)
8
8
 
9
- **“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
9
+ **“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
10
10
 
11
11
  ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, token count, and token rate](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo.gif)
12
12
 
@@ -58,6 +58,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
58
58
  ║ [■] Token activity monitor color ‹ accent › ║
59
59
  ║ [■] Token activity monitor direction ‹ Right to Left › ║
60
60
  ║ [■] Token activity monitor dimmed OFF ║
61
+ ║ [■] Token activity monitor upper bound ‹ 80 tps › ║
61
62
  ║ [■] Elapsed time since prompt ON ║
62
63
  ║ [■] Show output tokens ON ║
63
64
  ║ [■] Token rate ON ║
@@ -108,7 +109,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
108
109
  ╟───────────────────────────────────────────────────────────────╢
109
110
  ║ ↑↓ move PgUp/PgDn page ←→ select ║
110
111
  ║ ␣ toggle ⏎ apply esc cancel ║
111
- ╚═══════════════════════════════════════════════════════[ 9/56 ]╝
112
+ ╚═══════════════════════════════════════════════════════[ 9/57 ]╝
112
113
  ```
113
114
 
114
115
  ### Word packs
@@ -144,6 +145,8 @@ full brightness for 1.5 seconds, then fades through five theme-aware shades to t
144
145
  the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
145
146
  restarts the cycle.
146
147
 
148
+ The token activity monitor reaches full `⣿` at or above its configurable upper bound (80 tps by default), with lower levels scaled proportionally to that bound. In `/topping-settings`, `←`/`→` cycle through presets spanning 10–1000 tps, and `␣` (Space) lets you enter any whole number from 10–1000. Invalid typed entries show an inline error and keep the editor open without changing the current value; invalid hand-edited `meterUpperBoundTps` values in `settings.json` revert to 80 on load.
149
+
147
150
  The response model is captured from assistant responses, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
148
151
 
149
152
  ## Requirements
@@ -160,7 +163,7 @@ npm test
160
163
  npm run typecheck
161
164
  ```
162
165
 
163
- Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence and schema migrations, word-pack parsing, loading, and default-text selection, response-model loader and footer behavior, the `menu.ts` component, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
166
+ Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior and its configurable upper bound, timer resets, settings persistence and schema migrations, word-pack parsing, loading, and default-text selection, response-model loader and footer behavior, the `menu.ts` component and its typed number entry, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
164
167
 
165
168
  ## Limitations
166
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.8.1",
3
+ "version": "0.9.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",
@@ -30,8 +30,8 @@
30
30
  "@earendil-works/pi-tui": "*"
31
31
  },
32
32
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.86.0",
34
- "@earendil-works/pi-tui": "0.86.0",
33
+ "@earendil-works/pi-coding-agent": "0.99.1",
34
+ "@earendil-works/pi-tui": "0.99.1",
35
35
  "@types/node": "^24.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },
@@ -15,6 +15,9 @@ export const ActivityMeterLevel = {
15
15
  export type ActivityMeterLevel = (typeof ActivityMeterLevel)[keyof typeof ActivityMeterLevel];
16
16
 
17
17
  const EMA_ALPHA = 0.4;
18
+ export const DEFAULT_METER_UPPER_BOUND_TPS = 80;
19
+ export const METER_UPPER_BOUND_TPS_MIN = 10;
20
+ export const METER_UPPER_BOUND_TPS_MAX = 1000;
18
21
  const BRAILLE: Record<ActivityMeterLevel, string> = {
19
22
  [ActivityMeterLevel.IDLE]: "⢀",
20
23
  [ActivityMeterLevel.LOW]: "⣀",
@@ -26,13 +29,19 @@ const BRAILLE: Record<ActivityMeterLevel, string> = {
26
29
  [ActivityMeterLevel.FULL]: "⣿",
27
30
  };
28
31
  const WIDTH = 8;
29
- const RATE_THRESHOLDS = [0, 5, 10, 15, 22, 30, 40] as const;
32
+ const LEGACY_FULL_SCALE_TPS = 40;
33
+ // Levels below full retain the original 40 tps scale's proportions.
34
+ const RATE_THRESHOLDS = [0, 5, 10, 15, 22, 30] as const;
30
35
  type CellColorizer = (level: ActivityMeterLevel, char: string) => string;
31
36
 
32
- /** Convert an estimated output-token rate to a display level. */
33
- export function rateToLevel(tokensPerSecond: number): ActivityMeterLevel {
37
+ /**
38
+ * Convert an estimated output-token rate to a display level. Rates at or above `upperBoundTps`
39
+ * render as full; the lower levels scale in proportion to the bound.
40
+ */
41
+ export function rateToLevel(tokensPerSecond: number, upperBoundTps: number = DEFAULT_METER_UPPER_BOUND_TPS): ActivityMeterLevel {
42
+ if (tokensPerSecond >= upperBoundTps) return ActivityMeterLevel.FULL;
34
43
  for (let i = RATE_THRESHOLDS.length - 1; i >= 0; i--) {
35
- if (tokensPerSecond > RATE_THRESHOLDS[i]) return (i + 1) as ActivityMeterLevel;
44
+ if (tokensPerSecond * LEGACY_FULL_SCALE_TPS > RATE_THRESHOLDS[i]! * upperBoundTps) return (i + 1) as ActivityMeterLevel;
36
45
  }
37
46
  return ActivityMeterLevel.IDLE;
38
47
  }
package/src/menu.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * contract and `ctx.ui.custom()` overlay API.
5
5
  *
6
6
  * Renders a titled box containing one or more sections of boolean toggle,
7
- * multi-value cycle, or keyboard-reorderable items, plus an optional live-updating
7
+ * multi-value cycle (including optional typed whole-number entry), or keyboard-reorderable items, plus an optional live-updating
8
8
  * preview section driven by a caller-supplied render callback.
9
9
  *
10
10
  * Intended to be reused by any extension that needs a simple modal toggle
@@ -12,10 +12,16 @@
12
12
  */
13
13
 
14
14
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
15
- import { type Component, Key, matchesKey, truncateToWidth, type SizeValue, type TUI, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { type Component, decodeKittyPrintable, Key, matchesKey, truncateToWidth, type SizeValue, type TUI, visibleWidth } from "@earendil-works/pi-tui";
16
16
 
17
17
  export type MenuValue = boolean | string;
18
18
 
19
+ export interface MenuNumberEntry {
20
+ min: number;
21
+ max: number;
22
+ unit?: string;
23
+ }
24
+
19
25
  /** Lines to display and an optional delay before the preview should refresh. */
20
26
  export interface PreviewResult {
21
27
  lines: string[];
@@ -40,6 +46,8 @@ export interface MenuItem {
40
46
  cycleEnabled?: boolean;
41
47
  /** Value snapped to when the gating checkbox is unchecked. */
42
48
  cycleDisabledValue?: string;
49
+ /** Also accept a typed whole number in [min, max]; space opens the editor. Values publish as decimal strings. */
50
+ numberEntry?: MenuNumberEntry;
43
51
  /**
44
52
  * Marks the item as a reorderable row. Space grabs/releases it; while grabbed,
45
53
  * up/down move it among the other rows sharing this group instead of moving the
@@ -81,6 +89,8 @@ export interface MenuResult<T> {
81
89
  }
82
90
 
83
91
  const DEFAULT_HINTS = ["\u2191\u2193 move", "PgUp/PgDn page", "\u2423 toggle", "\u23ce apply", "esc cancel"];
92
+ const EDIT_HINTS = ["type a whole number", "⌫ delete", "⏎ confirm", "esc cancel edit"];
93
+ const MAX_EDIT_CHARS = 8;
84
94
  const OVERLAY_WIDTH = "86%";
85
95
  export const DEFAULT_PREVIEW_WIDTH = 76;
86
96
  const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
@@ -97,6 +107,13 @@ interface FlatItem {
97
107
  sectionIndex: number;
98
108
  }
99
109
 
110
+ function parseWholeNumber(raw: string, spec: MenuNumberEntry): number | undefined {
111
+ const trimmed = raw.trim();
112
+ if (!/^\d+$/.test(trimmed)) return undefined;
113
+ const value = Number(trimmed);
114
+ return value >= spec.min && value <= spec.max ? value : undefined;
115
+ }
116
+
100
117
  function buildInitialValues(config: MenuConfig): Record<string, MenuValue> {
101
118
  const values: Record<string, MenuValue> = {};
102
119
  const reorderGroups = new Map<string, string[]>();
@@ -139,6 +156,7 @@ export class MenuComponent implements Component {
139
156
  private cachedRows: number | undefined;
140
157
  private cachedLines: string[] | undefined;
141
158
  private pageItemCount: number | undefined;
159
+ private editing: { buffer: string; invalid: boolean } | undefined;
142
160
 
143
161
  constructor(
144
162
  config: MenuConfig,
@@ -191,6 +209,10 @@ export class MenuComponent implements Component {
191
209
  }
192
210
  return;
193
211
  }
212
+ if (this.editing) {
213
+ this.handleEditInput(data);
214
+ return;
215
+ }
194
216
 
195
217
  // Map input to a normalized key name.
196
218
  let mappedKey: string | undefined;
@@ -220,6 +242,11 @@ export class MenuComponent implements Component {
220
242
  [Key.space]: () => {
221
243
  const item = this.flat[this.cursor]!.item;
222
244
  if (this.isDisabled(item)) return;
245
+ if (item.numberEntry) {
246
+ this.editing = { buffer: "", invalid: false };
247
+ this.invalidate();
248
+ return;
249
+ }
223
250
  if (item.cycleValues && item.cycleEnabledBy) {
224
251
  this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
225
252
  if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
@@ -251,6 +278,32 @@ export class MenuComponent implements Component {
251
278
  return lines;
252
279
  }
253
280
 
281
+ private handleEditInput(data: string): void {
282
+ const editing = this.editing!;
283
+ const item = this.flat[this.cursor]!.item;
284
+ const spec = item.numberEntry!;
285
+ if (matchesKey(data, Key.enter)) {
286
+ const value = parseWholeNumber(editing.buffer, spec);
287
+ if (value === undefined) editing.invalid = true;
288
+ else {
289
+ this.values[item.id] = String(value);
290
+ this.editing = undefined;
291
+ }
292
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
293
+ this.editing = undefined;
294
+ } else if (matchesKey(data, Key.backspace)) {
295
+ editing.buffer = [...editing.buffer].slice(0, -1).join("");
296
+ editing.invalid = false;
297
+ } else {
298
+ const text = decodeKittyPrintable(data) ?? data;
299
+ if (/^[\x20-\x7e]+$/.test(text) && editing.buffer.length + text.length <= MAX_EDIT_CHARS) {
300
+ editing.buffer += text;
301
+ editing.invalid = false;
302
+ }
303
+ }
304
+ this.invalidate();
305
+ }
306
+
254
307
  /**
255
308
  * Move the grabbed reorder row within its group, clamped at the group edges.
256
309
  * Returns true when the key belongs to the mover and must not also move the
@@ -299,7 +352,29 @@ export class MenuComponent implements Component {
299
352
  private cycleCurrentValue(delta: number): void {
300
353
  const item = this.flat[this.cursor]!.item;
301
354
  if (this.isDisabled(item) || !item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
302
- const current = item.cycleValues.indexOf(this.values[item.id] as string);
355
+ const currentValue = this.values[item.id] as string;
356
+ const current = item.cycleValues.indexOf(currentValue);
357
+ if (item.numberEntry && current === -1) {
358
+ const numericCurrent = Number(currentValue);
359
+ if (delta > 0) {
360
+ for (const preset of item.cycleValues) {
361
+ if (Number(preset) > numericCurrent) {
362
+ this.values[item.id] = preset;
363
+ this.invalidate();
364
+ return;
365
+ }
366
+ }
367
+ } else {
368
+ for (let i = item.cycleValues.length - 1; i >= 0; i--) {
369
+ const preset = item.cycleValues[i]!;
370
+ if (Number(preset) < numericCurrent) {
371
+ this.values[item.id] = preset;
372
+ this.invalidate();
373
+ return;
374
+ }
375
+ }
376
+ }
377
+ }
303
378
  const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
304
379
  this.values[item.id] = item.cycleValues[index]!;
305
380
  this.invalidate();
@@ -529,6 +604,13 @@ export class MenuComponent implements Component {
529
604
  }
530
605
 
531
606
  private renderHintsRow(innerWidth: number): string {
607
+ if (this.editing) {
608
+ const item = this.flat[this.cursor]!.item;
609
+ const spec = item.numberEntry!;
610
+ const unit = spec.unit ? ` ${spec.unit}` : "";
611
+ const error = this.editing.invalid ? `${this.theme.fg("error", `✗ Enter a whole number from ${spec.min} to ${spec.max}${unit}`)} ` : "";
612
+ return this.renderContentRow(` ${error}${this.theme.fg("dim", EDIT_HINTS.join(" "))}`, innerWidth);
613
+ }
532
614
  const plain = ` ${this.hints.join(" ")}`;
533
615
  return this.renderContentRow(this.theme.fg("dim", plain), innerWidth);
534
616
  }
@@ -559,13 +641,17 @@ export class MenuComponent implements Component {
559
641
  if (item.cycleValues) {
560
642
  const enabled = !this.isDisabled(item) && (item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true);
561
643
  const rawValue = typeof value === "string" ? value : String(value);
562
- const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue;
563
- const stateWord = `‹ ${displayValue} ›`;
564
- const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
644
+ const unit = item.numberEntry?.unit ? ` ${item.numberEntry.unit}` : "";
645
+ const displayValue = item.numberEntry ? `${rawValue}${unit}` : item.cycleValueLabels?.[rawValue] ?? rawValue;
646
+ const editing = selected ? this.editing : undefined;
647
+ const stateWord = editing ? `‹ ${editing.buffer}▌${unit} ›` : `‹ ${displayValue} ›`;
648
+ const rightPlain = `${stateWord} `;
649
+ const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
565
650
  const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
566
651
  const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
567
652
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
568
- const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
653
+ const state = editing ? th.fg(editing.invalid ? "error" : "accent", stateWord) : enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord);
654
+ const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${state} `;
569
655
  return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
570
656
  }
571
657
 
package/src/preview.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
- import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
3
+ import { ActivityMeter, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN, rateToLevel } from "./activity-meter.ts";
4
4
  import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRate, getThinkingLevelColorizer, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
5
5
  import { buildLoaderMessage } from "./loader-message.ts";
6
6
  import { getResponseModelColorizer } from "./nvidia-green.ts";
7
7
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
8
- import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
8
+ import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleMeterUpperBound, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
9
9
  import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, wordPacksPath, type WordPack } from "./word-packs.ts";
10
10
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
11
11
  // flat 28 tps for the token readouts.
@@ -49,12 +49,15 @@ export class PreviewRenderer {
49
49
  } else if (values.elapsedTime !== false || values.outputTokens !== false) {
50
50
  nextRefreshInMs = ELAPSED_INTERVAL_MS;
51
51
  }
52
- return { lines: ["", this.loaderPreview(values, elapsedMs), ""], nextRefreshInMs };
52
+ const help = activeItemId === "meterUpperBoundTps"
53
+ ? this.#ctx.ui.theme.fg("dim", `⣿ at ${fromCycleMeterUpperBound(values.meterUpperBoundTps)} tps or more · ←→ presets · ␣ type ${METER_UPPER_BOUND_TPS_MIN}–${METER_UPPER_BOUND_TPS_MAX}`)
54
+ : "";
55
+ return { lines: ["", this.loaderPreview(values, elapsedMs), help], nextRefreshInMs };
53
56
  }
54
57
  private loaderPreview(values: Record<string, boolean | string>, elapsedMs: number): string {
55
58
  this.#meter.setDirection(fromCycleDirection(values.meterDirection));
56
59
  if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
57
- this.#meter.push(rateToLevel(meterRate(elapsedMs)));
60
+ this.#meter.push(rateToLevel(meterRate(elapsedMs), fromCycleMeterUpperBound(values.meterUpperBoundTps)));
58
61
  this.#lastMeterUpdate = elapsedMs;
59
62
  }
60
63
  const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
package/src/session.ts CHANGED
@@ -632,7 +632,7 @@ export class SessionManager {
632
632
  hasNewTokenCount = total > state.lastTokenRateTotal;
633
633
  state.lastTokenRateTotal = total;
634
634
  const tokenRate = state.rateTracker.sample(total, now);
635
- if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
635
+ if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate, decorations.meterUpperBoundTps));
636
636
  state.lastTokenRateSampledAt = now;
637
637
  }
638
638
  const responseModelShown = features.responseModel && state.responseModel
package/src/settings.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import { DEFAULT_METER_UPPER_BOUND_TPS, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN } from "./activity-meter.ts";
4
5
  import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
5
- import type { MenuSection } from "./menu.ts";
6
+ import type { MenuNumberEntry, MenuSection } from "./menu.ts";
6
7
  import { isWordPackEnabled, isWordPackId, WORD_PACK_MENU_PREFIX, type WordPack } from "./word-packs.ts";
7
8
  import { isPlainObject } from "./util.ts";
8
9
 
@@ -29,6 +30,7 @@ export const DONE_MARKER_BORDER_STYLE_VALUES = [...BORDER_STYLE_VALUES, "none"]
29
30
  export type DoneMarkerBorderStyle = (typeof DONE_MARKER_BORDER_STYLE_VALUES)[number];
30
31
  export const DONE_MARKER_STYLE_VALUES = ["elite", "bookend"] as const;
31
32
  export type DoneMarkerStyle = (typeof DONE_MARKER_STYLE_VALUES)[number];
33
+ export const METER_UPPER_BOUND_TPS_PRESETS = ["10", "20", "30", "40", "50", "60", "80", "100", "150", "200", "300", "500", "1000"] as const;
32
34
 
33
35
  export function isBorderStyle(value: unknown): value is BorderStyle {
34
36
  return typeof value === "string" && BORDER_STYLE_VALUES.some(style => style === value);
@@ -54,6 +56,19 @@ export const isSpinnerColor = isThinkingLevelSettingColor;
54
56
  export const isPromptBorderColor = isThinkingLevelSettingColor;
55
57
  export const isDoneMarkerBorderColor = isThinkingLevelSettingColor;
56
58
 
59
+ export function parseMeterUpperBoundTps(value: unknown): number | undefined {
60
+ if (typeof value === "number") {
61
+ return Number.isInteger(value) && value >= METER_UPPER_BOUND_TPS_MIN && value <= METER_UPPER_BOUND_TPS_MAX ? value : undefined;
62
+ }
63
+ if (typeof value !== "string" || !/^\s*\d+\s*$/.test(value)) return undefined;
64
+ const parsed = Number(value);
65
+ return parsed >= METER_UPPER_BOUND_TPS_MIN && parsed <= METER_UPPER_BOUND_TPS_MAX ? parsed : undefined;
66
+ }
67
+
68
+ export function fromCycleMeterUpperBound(value: unknown): number {
69
+ return parseMeterUpperBoundTps(value) ?? DEFAULT_METER_UPPER_BOUND_TPS;
70
+ }
71
+
57
72
  export interface DecoratorSettings {
58
73
  decorations: {
59
74
  animatedSpinner: boolean;
@@ -81,6 +96,7 @@ export interface DecoratorSettings {
81
96
  meterColor: ThinkingLevelColor;
82
97
  meterColorEnabled: boolean;
83
98
  meterDimmed: boolean;
99
+ meterUpperBoundTps: number;
84
100
  tokenRateColor: ThinkingLevelColor;
85
101
  tokenRateDimmed: boolean;
86
102
  responseModelColor: ThinkingLevelColor;
@@ -111,7 +127,7 @@ export interface DecoratorSettings {
111
127
  }
112
128
 
113
129
  export const DEFAULT_SETTINGS: DecoratorSettings = {
114
- 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", doneMarkerModelColor: "muted", doneMarkerModelDimmed: false, 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 },
130
+ 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", doneMarkerModelColor: "muted", doneMarkerModelDimmed: false, spinnerColor: "thinking-level", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, meterUpperBoundTps: DEFAULT_METER_UPPER_BOUND_TPS, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
115
131
  features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true, includeDefaultWorkingText: true },
116
132
  loaderOrder: [...DEFAULT_LOADER_ORDER],
117
133
  wordPacks: {},
@@ -146,12 +162,12 @@ export function parseLoaderOrder(value: unknown): LoaderElement[] {
146
162
  }
147
163
 
148
164
  export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
149
- function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, parsed: unknown): T {
165
+ function mergeGroup<T extends Record<string, boolean | string | number>>(defaults: T, parsed: unknown): T {
150
166
  const merged = { ...defaults };
151
167
  if (!isPlainObject(parsed)) return merged;
152
168
  for (const [key, value] of Object.entries(parsed)) {
153
169
  if (!Object.hasOwn(merged, key)) continue;
154
- if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
170
+ if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string | number>)[key] = value;
155
171
  }
156
172
  return merged;
157
173
  }
@@ -171,8 +187,8 @@ type DecorationSettings = DecoratorSettings["decorations"];
171
187
  type FeatureSettings = DecoratorSettings["features"];
172
188
  type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
173
189
  type MenuEntryBase = { id: string; label: string; section: MenuSectionName };
174
- type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleValueLabels?: Readonly<Record<string, string>>; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string };
175
- type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleValueLabels?: never; cycleEnabledBy?: never; cycleDisabledValue?: never };
190
+ type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleValueLabels?: Readonly<Record<string, string>>; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string; numberEntry?: MenuNumberEntry };
191
+ type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleValueLabels?: never; cycleEnabledBy?: never; cycleDisabledValue?: never; numberEntry?: never };
176
192
  type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
177
193
  const THINKING_LEVEL_CYCLE_LABELS = { "thinking-level": "thinkingLevel" } as const;
178
194
  const DIRECTION_CYCLE_LABELS = { ltr: "Left to Right", rtl: "Right to Left" } as const;
@@ -196,6 +212,7 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
196
212
  { 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" },
197
213
  { 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
214
  { id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
215
+ { id: "meterUpperBoundTps", label: "Token activity monitor upper bound", section: "“Working” Loader", group: "decorations", key: "meterUpperBoundTps", cycleValues: METER_UPPER_BOUND_TPS_PRESETS, numberEntry: { min: METER_UPPER_BOUND_TPS_MIN, max: METER_UPPER_BOUND_TPS_MAX, unit: "tps" } },
199
216
  { id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
200
217
  { id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" },
201
218
  // id differs from key: the Elements Order row already owns "tokenRate" in the menu's shared value namespace.
@@ -227,7 +244,7 @@ function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["i
227
244
  const cycleEnabled = entry.group === "decorations" && entry.cycleEnabledBy
228
245
  ? settings.decorations[entry.cycleEnabledBy]
229
246
  : undefined;
230
- return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleValueLabels: entry.cycleValueLabels, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, value };
247
+ return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleValueLabels: entry.cycleValueLabels, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, numberEntry: entry.numberEntry, value: typeof value === "number" ? String(value) : value };
231
248
  }
232
249
 
233
250
  function isDecorationBooleanKey(key: keyof DecorationSettings): key is DecorationBooleanKey {
@@ -263,6 +280,11 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
263
280
  case "shimmerSpeed":
264
281
  if (value === "slow" || value === "normal" || value === "fast") decorations[key] = value;
265
282
  return;
283
+ case "meterUpperBoundTps": {
284
+ const bound = parseMeterUpperBoundTps(value);
285
+ if (bound !== undefined) decorations[key] = bound;
286
+ return;
287
+ }
266
288
  default:
267
289
  // Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here.
268
290
  throw new Error(`Unhandled cycle setting: ${String(key)}`);
@@ -298,7 +320,7 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
298
320
  for (const entry of MENU_ENTRIES) {
299
321
  const value = values[entry.id];
300
322
  if (value === undefined) continue;
301
- if (entry.group === "decorations" && entry.cycleValues && typeof value === "string" && entry.cycleValues.includes(value)) {
323
+ if (entry.group === "decorations" && entry.cycleValues && typeof value === "string" && (entry.cycleValues.includes(value) || entry.numberEntry !== undefined)) {
302
324
  setDecorationCycleValue(next.decorations, entry.key, value);
303
325
  if (entry.cycleEnabledBy) next.decorations[entry.cycleEnabledBy] = values[entry.cycleEnabledBy] !== false;
304
326
  } else if (entry.group === "decorations" && typeof value === "boolean" && isDecorationBooleanKey(entry.key)) {
@@ -324,7 +346,8 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
324
346
  * Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and
325
347
  * `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker
326
348
  * border color is reset. Below 4, the response-model footer inherits the response-model
327
- * setting. A legacy `features.simCityWorkingText: true` enables the
349
+ * setting. A missing, non-integer, or out-of-range meter upper bound resolves
350
+ * to the default of 80 tps. A legacy `features.simCityWorkingText: true` enables the
328
351
  * `simcity` pack when no explicit preference is stored.
329
352
  */
330
353
  export function loadSettings(): DecoratorSettings {
@@ -349,6 +372,8 @@ export function loadSettings(): DecoratorSettings {
349
372
  setDecorationCycleValue(settings.decorations, k,
350
373
  value === "default" && (k === "spinnerColor" || k === "doneMarkerBorderColor") ? "thinking-level" : value);
351
374
  }
375
+ const bound = parseMeterUpperBoundTps(parsed.decorations.meterUpperBoundTps);
376
+ if (bound !== undefined) settings.decorations.meterUpperBoundTps = bound;
352
377
  }
353
378
  const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
354
379
  if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) {