@underactive/pi-topping 0.8.2 → 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 +6 -3
- package/package.json +1 -1
- package/src/activity-meter.ts +13 -4
- package/src/menu.ts +93 -7
- package/src/preview.ts +7 -4
- package/src/session.ts +1 -1
- package/src/settings.ts +34 -9
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
|

|
|
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
|

|
|
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/
|
|
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.
|
|
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",
|
package/src/activity-meter.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
|
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
|
|
563
|
-
const
|
|
564
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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) {
|