@underactive/pi-topping 0.3.1 → 0.4.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 +23 -8
- package/package.json +5 -5
- package/src/format.ts +56 -13
- package/src/menu.ts +73 -10
- package/src/preview.ts +21 -8
- package/src/prompt-decorator.ts +14 -3
- package/src/session.ts +64 -27
- package/src/settings.ts +82 -18
package/README.md
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is a [Pi](https://github.com/earendil-works/pi-coding-agent) extension that adds some quality of life improvements to Pi's UX, while looking good in the process:
|
|
4
4
|
|
|
5
|
-
**User Prompt** — high-vis bordered prompt box with configurable border color, pi icon toggle, and timestamp.
|
|
5
|
+
**User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, and timestamp.
|
|
6
6
|
|
|
7
|
-

|
|
7
|
+

|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, shimmer (with direction and speed), token activity meter (with color, direction, and dim toggle), elapsed timer, and output token display — all arrangeable left to right.
|
|
10
10
|
|
|
11
|
-

|
|
11
|
+

|
|
12
12
|
|
|
13
|
-
**Completion Marker** — end-of-turn marker with icon, randomized verb, and token consumption display. Hooks into Pi's `agent_settled` event.
|
|
13
|
+
**Completion Marker** — end-of-turn marker with icon, randomized verb, and token consumption display. If you steered or followed up (`Alt+Enter`) while Pi was working, the marker also tallies those, e.g. `π Whisked for 2s (↓ 55 tokens · 2 mid-turn inputs)`. Hooks into Pi's `agent_settled` event.
|
|
14
14
|
|
|
15
|
-

|
|
15
|
+

|
|
16
16
|
|
|
17
17
|
## Settings
|
|
18
18
|
|
|
@@ -27,15 +27,17 @@ Run `/topping-settings` (TUI only) to customize your toppings:
|
|
|
27
27
|
╟─ User Prompt ──────────────────────────────────────────────╢
|
|
28
28
|
║ [■] High-vis prompt ON ║
|
|
29
29
|
║ [■] Border color ‹ accent › ║
|
|
30
|
+
║ [■] Border style ‹ double › ║
|
|
30
31
|
║ [■] Pi icon ON ║
|
|
31
32
|
║ [■] Timestamp ON ║
|
|
32
33
|
║ ║
|
|
33
|
-
╟─ Working Loader
|
|
34
|
+
╟─ “Working” Loader ─────────────────────────────────────────╢
|
|
34
35
|
║ [■] Animated spinner ON ║
|
|
35
36
|
║ ▸ [■] Animated spinner color ‹ accent › ║
|
|
36
37
|
║ [■] Randomize "Working" text ON ║
|
|
37
38
|
║ [■] Text shimmer ON ║
|
|
38
39
|
║ [■] Text shimmer direction ‹ Left to Right › ║
|
|
40
|
+
║ [■] Text shimmer speed ‹ Normal › ║
|
|
39
41
|
║ [■] Token activity monitor ON ║
|
|
40
42
|
║ [■] Token activity monitor color ‹ accent › ║
|
|
41
43
|
║ [■] Token activity monitor direction ‹ Left to Right › ║
|
|
@@ -43,19 +45,32 @@ Run `/topping-settings` (TUI only) to customize your toppings:
|
|
|
43
45
|
║ [■] Elapsed time since prompt ON ║
|
|
44
46
|
║ [■] Show output tokens ON ║
|
|
45
47
|
║ ║
|
|
48
|
+
╟─ Elements Order ───────────────────────────────────────────╢
|
|
49
|
+
║ [ ] Animated spinner ║
|
|
50
|
+
║ [■] “Working” text ↑ ↓ ║
|
|
51
|
+
║ [ ] Token activity monitor ║
|
|
52
|
+
║ [ ] Elapsed time ║
|
|
53
|
+
║ [ ] Output tokens ║
|
|
54
|
+
║ ║
|
|
46
55
|
╟─ Completion Marker ────────────────────────────────────────╢
|
|
47
56
|
║ [■] Show completion marker ON ║
|
|
48
57
|
║ [■] Pi icon ON ║
|
|
49
58
|
║ [■] Randomize "Worked" text ON ║
|
|
50
59
|
║ [■] Tokens spent ON ║
|
|
60
|
+
║ [■] Mid-turn inputs ON ║
|
|
51
61
|
║ ║
|
|
52
62
|
╟─ Options ──────────────────────────────────────────────────╢
|
|
53
63
|
║ [■] Use NerdFont icons ON ║
|
|
54
64
|
╟────────────────────────────────────────────────────────────╢
|
|
55
65
|
║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
|
|
56
|
-
|
|
66
|
+
╚════════════════════════════════════════════════════┥ 1/28 ┝╝
|
|
57
67
|
```
|
|
58
68
|
|
|
69
|
+
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
70
|
+
left or right within the loader. Elapsed time and output tokens share a single
|
|
71
|
+
`(3s · ↓ 84 tokens)` parenthetical whenever they end up next to each other, and split
|
|
72
|
+
into `(3s)` and `(↓ 84 tokens)` when they do not.
|
|
73
|
+
|
|
59
74
|
## Install
|
|
60
75
|
|
|
61
76
|
```bash
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
4
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"author": "Eric Sison",
|
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
"@earendil-works/pi-tui": "*"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
34
|
-
"@earendil-works/pi-tui": "0.
|
|
33
|
+
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
34
|
+
"@earendil-works/pi-tui": "0.82.1",
|
|
35
35
|
"@types/node": "^24.0.0",
|
|
36
36
|
"typescript": "^5.9.3"
|
|
37
37
|
},
|
|
38
38
|
"pi": {
|
|
39
39
|
"extensions": ["./index.ts"],
|
|
40
|
-
"
|
|
40
|
+
"image": "https://raw.githubusercontent.com/underactive/pi-topping/main/media/working.png"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/format.ts
CHANGED
|
@@ -3,6 +3,18 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
/** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
|
|
4
4
|
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
5
|
|
|
6
|
+
/** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
|
|
7
|
+
export const SPINNER_FRAME_MS = 80;
|
|
8
|
+
|
|
9
|
+
/** A user-orderable piece of the working indicator. */
|
|
10
|
+
export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens";
|
|
11
|
+
|
|
12
|
+
/** Left-to-right element order matching pi's stock working indicator. */
|
|
13
|
+
export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "elapsed", "tokens"];
|
|
14
|
+
|
|
15
|
+
/** Elements rendered inside the dim parenthetical rather than as standalone segments. */
|
|
16
|
+
const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens"]);
|
|
17
|
+
|
|
6
18
|
/** Format an output-token count for the working indicator. */
|
|
7
19
|
export function formatTokens(count: number): string {
|
|
8
20
|
if (count < 1000) return count.toString();
|
|
@@ -52,19 +64,37 @@ export function isFullyDefaultAppearance(features: {
|
|
|
52
64
|
return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Assemble the working indicator from already-styled pieces, laid out in `order`.
|
|
69
|
+
*
|
|
70
|
+
* Empty/omitted pieces are dropped, and any run of detail elements left adjacent
|
|
71
|
+
* after that collapses into a single dim parenthetical.
|
|
72
|
+
*/
|
|
56
73
|
export function buildWorkingMessage(
|
|
57
74
|
theme: Pick<Theme, "fg">,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
elapsed: string,
|
|
61
|
-
tokens: string,
|
|
62
|
-
features: { elapsedTime: boolean; outputTokens: boolean },
|
|
75
|
+
parts: Partial<Record<LoaderElement, string>>,
|
|
76
|
+
order: readonly LoaderElement[] = DEFAULT_LOADER_ORDER,
|
|
63
77
|
): string {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
const segments: string[] = [];
|
|
79
|
+
let details: string[] = [];
|
|
80
|
+
const flushDetails = (): void => {
|
|
81
|
+
if (details.length) segments.push(theme.fg("dim", `(${details.join(" · ")})`));
|
|
82
|
+
details = [];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (const element of order) {
|
|
86
|
+
const value = parts[element];
|
|
87
|
+
if (!value) continue;
|
|
88
|
+
if (DETAIL_ELEMENTS.has(element)) {
|
|
89
|
+
details.push(value);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
flushDetails();
|
|
93
|
+
segments.push(value);
|
|
94
|
+
}
|
|
95
|
+
flushDetails();
|
|
96
|
+
|
|
97
|
+
return segments.join(" ");
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
export function shimmerString(
|
|
@@ -72,6 +102,7 @@ export function shimmerString(
|
|
|
72
102
|
elapsedMs: number,
|
|
73
103
|
theme: Pick<Theme, "getFgAnsi">,
|
|
74
104
|
direction: "ltr" | "rtl" = "ltr",
|
|
105
|
+
speed: "slow" | "normal" | "fast" = "normal",
|
|
75
106
|
): string {
|
|
76
107
|
const chars = [...text];
|
|
77
108
|
if (chars.length === 0) return "";
|
|
@@ -81,9 +112,21 @@ export function shimmerString(
|
|
|
81
112
|
const SHIMMER_BASE = ansiToRgb(theme.getFgAnsi("dim"));
|
|
82
113
|
const SHIMMER_HIGHLIGHT = ansiToRgb(theme.getFgAnsi("text"));
|
|
83
114
|
const period = chars.length + SHIMMER_PADDING * 2;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
const unitsPerS = period / SHIMMER_SWEEP_S;
|
|
116
|
+
// The band crosses padding at either end while every character is still dim. Scaling only
|
|
117
|
+
// the stretch where it actually overlaps the text keeps that dark pause identical at every
|
|
118
|
+
// speed, so `speed` changes the sweep alone rather than the whole cycle.
|
|
119
|
+
const litEnter = SHIMMER_PADDING - SHIMMER_BAND_HALF;
|
|
120
|
+
const litExit = SHIMMER_PADDING + chars.length - 1 + SHIMMER_BAND_HALF;
|
|
121
|
+
const enterS = litEnter / unitsPerS;
|
|
122
|
+
const litS = (litExit - litEnter) / unitsPerS / (speed === "slow" ? 0.5 : speed === "fast" ? 2 : 1);
|
|
123
|
+
const phase = (elapsedMs / 1000) % (enterS + litS + (period - litExit) / unitsPerS);
|
|
124
|
+
const linear = phase < enterS
|
|
125
|
+
? phase * unitsPerS
|
|
126
|
+
: phase < enterS + litS
|
|
127
|
+
? litEnter + ((phase - enterS) / litS) * (litExit - litEnter)
|
|
128
|
+
: litExit + (phase - enterS - litS) * unitsPerS;
|
|
129
|
+
const pos = direction === "rtl" ? period - linear : linear;
|
|
87
130
|
|
|
88
131
|
return chars
|
|
89
132
|
.map((ch, i) => {
|
package/src/menu.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* extensions, built on top of `@earendil-works/pi-tui`'s `Component`
|
|
6
6
|
* contract and `ctx.ui.custom()` overlay API.
|
|
7
7
|
*
|
|
8
|
-
* Renders a titled box containing one or more sections of boolean toggle
|
|
9
|
-
*
|
|
10
|
-
* caller-supplied render callback, e.g.:
|
|
8
|
+
* Renders a titled box containing one or more sections of boolean toggle,
|
|
9
|
+
* multi-value cycle, or drag-to-reorder items, plus an optional live-updating
|
|
10
|
+
* preview section driven by a caller-supplied render callback, e.g.:
|
|
11
11
|
*
|
|
12
12
|
* ╔═[ Pi Topping: Settings ]════════════════════════╗
|
|
13
13
|
* ╟─ Preview ─────────────────────────────────────╢
|
|
@@ -49,6 +49,13 @@ export interface MenuItem {
|
|
|
49
49
|
cycleEnabled?: boolean;
|
|
50
50
|
/** Value snapped to when the gating checkbox is unchecked. */
|
|
51
51
|
cycleDisabledValue?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Marks the item as a reorderable row. Space grabs/releases it; while grabbed,
|
|
54
|
+
* up/down move it among the other rows sharing this group instead of moving the
|
|
55
|
+
* cursor. The group's current order is published as a comma-joined list of item
|
|
56
|
+
* ids under this key in the result values.
|
|
57
|
+
*/
|
|
58
|
+
reorderGroup?: string;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
export interface MenuSection {
|
|
@@ -90,18 +97,26 @@ interface FlatItem {
|
|
|
90
97
|
cycleValues?: readonly string[];
|
|
91
98
|
cycleEnabledBy?: string;
|
|
92
99
|
cycleDisabledValue?: string;
|
|
100
|
+
reorderGroup?: string;
|
|
93
101
|
item: MenuItem;
|
|
94
102
|
sectionIndex: number;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
function buildInitialValues(config: MenuConfig): Record<string, MenuValue> {
|
|
98
106
|
const values: Record<string, MenuValue> = {};
|
|
107
|
+
const reorderGroups = new Map<string, string[]>();
|
|
99
108
|
for (const section of config.sections) {
|
|
100
109
|
for (const item of section.items) {
|
|
101
110
|
values[item.id] = item.value;
|
|
102
111
|
if (item.cycleEnabledBy) values[item.cycleEnabledBy] = item.cycleEnabled ?? true;
|
|
112
|
+
if (item.reorderGroup) {
|
|
113
|
+
const ids = reorderGroups.get(item.reorderGroup) ?? [];
|
|
114
|
+
ids.push(item.id);
|
|
115
|
+
reorderGroups.set(item.reorderGroup, ids);
|
|
116
|
+
}
|
|
103
117
|
}
|
|
104
118
|
}
|
|
119
|
+
for (const [group, ids] of reorderGroups) values[group] = ids.join(",");
|
|
105
120
|
return values;
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -144,7 +159,7 @@ export class MenuComponent implements Component {
|
|
|
144
159
|
this.flat = [];
|
|
145
160
|
for (const [sectionIndex, section] of config.sections.entries()) {
|
|
146
161
|
for (const item of section.items) {
|
|
147
|
-
this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, item, sectionIndex });
|
|
162
|
+
this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, reorderGroup: item.reorderGroup, item, sectionIndex });
|
|
148
163
|
}
|
|
149
164
|
}
|
|
150
165
|
this.initialValues = { ...this.values };
|
|
@@ -191,10 +206,12 @@ export class MenuComponent implements Component {
|
|
|
191
206
|
|
|
192
207
|
const keyActions: Record<string, () => void> = {
|
|
193
208
|
[Key.up]: () => {
|
|
209
|
+
if (this.moveGrabbedItem(-1)) return;
|
|
194
210
|
this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
|
|
195
211
|
this.invalidate();
|
|
196
212
|
},
|
|
197
213
|
[Key.down]: () => {
|
|
214
|
+
if (this.moveGrabbedItem(1)) return;
|
|
198
215
|
this.cursor = (this.cursor + 1) % this.flat.length;
|
|
199
216
|
this.invalidate();
|
|
200
217
|
},
|
|
@@ -233,6 +250,32 @@ export class MenuComponent implements Component {
|
|
|
233
250
|
return lines;
|
|
234
251
|
}
|
|
235
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Move the grabbed reorder row one slot within its group, clamped at the group
|
|
255
|
+
* edges. Returns true when the key belongs to the mover and must not also move
|
|
256
|
+
* the cursor — which is what keeps a grab from escaping its group.
|
|
257
|
+
*/
|
|
258
|
+
private moveGrabbedItem(delta: number): boolean {
|
|
259
|
+
const grabbed = this.flat[this.cursor]!;
|
|
260
|
+
const group = grabbed.reorderGroup;
|
|
261
|
+
if (group === undefined || this.values[grabbed.id] !== true) return false;
|
|
262
|
+
|
|
263
|
+
let start = this.cursor;
|
|
264
|
+
while (start > 0 && this.flat[start - 1]!.reorderGroup === group) start--;
|
|
265
|
+
let end = this.cursor;
|
|
266
|
+
while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
|
|
267
|
+
|
|
268
|
+
const target = this.cursor + delta;
|
|
269
|
+
if (target >= start && target <= end) {
|
|
270
|
+
this.flat[this.cursor] = this.flat[target]!;
|
|
271
|
+
this.flat[target] = grabbed;
|
|
272
|
+
this.cursor = target;
|
|
273
|
+
this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
|
|
274
|
+
this.invalidate();
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
236
279
|
private cycleCurrentValue(delta: number): void {
|
|
237
280
|
const item = this.flat[this.cursor]!;
|
|
238
281
|
if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
@@ -279,13 +322,20 @@ export class MenuComponent implements Component {
|
|
|
279
322
|
return [this.renderSectionDivider("Preview", innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
|
|
280
323
|
}
|
|
281
324
|
|
|
325
|
+
/** Render every section from `this.flat`, the single source of truth for row order. */
|
|
282
326
|
private buildToggleSections(innerWidth: number): string[] {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
327
|
+
const lines: string[] = [];
|
|
328
|
+
let currentSection = -1;
|
|
329
|
+
for (const [index, flat] of this.flat.entries()) {
|
|
330
|
+
if (flat.sectionIndex !== currentSection) {
|
|
331
|
+
if (currentSection !== -1) lines.push(this.renderBlankRow(innerWidth));
|
|
332
|
+
lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
|
|
333
|
+
currentSection = flat.sectionIndex;
|
|
334
|
+
}
|
|
335
|
+
lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
|
|
336
|
+
}
|
|
337
|
+
if (lines.length) lines.push(this.renderBlankRow(innerWidth));
|
|
338
|
+
return lines;
|
|
289
339
|
}
|
|
290
340
|
|
|
291
341
|
/** Build a section-aware item window, keeping a divider above each visible section. */
|
|
@@ -446,6 +496,19 @@ export class MenuComponent implements Component {
|
|
|
446
496
|
const marker = selected ? "\u25b8" : " ";
|
|
447
497
|
const markerColored = selected ? th.fg("accent", marker) : marker;
|
|
448
498
|
|
|
499
|
+
if (item.reorderGroup) {
|
|
500
|
+
const held = value as boolean;
|
|
501
|
+
const stateWord = held ? "\u2191 \u2193" : "";
|
|
502
|
+
const rightPlain = `${stateWord} `;
|
|
503
|
+
const fixedLeftLen = 8;
|
|
504
|
+
const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
|
|
505
|
+
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
506
|
+
const leftPlain = ` ${marker} [${held ? "\u25a0" : " "}] ${label}`;
|
|
507
|
+
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
508
|
+
const content = ` ${markerColored} [${held ? th.fg("accent", "\u25a0") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${held ? th.fg("accent", stateWord) : ""} `;
|
|
509
|
+
return this.wrap("\u2551", content, "\u2551");
|
|
510
|
+
}
|
|
511
|
+
|
|
449
512
|
if (item.cycleValues) {
|
|
450
513
|
const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
|
|
451
514
|
const stateWord = `‹ ${value} ›`;
|
package/src/preview.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { PreviewResult } from "./menu.ts";
|
|
3
3
|
import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
|
|
4
|
-
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, SPINNER_FRAMES } from "./format.ts";
|
|
4
|
+
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
|
|
5
5
|
import { buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
6
|
+
import { LOADER_ORDER_ID, parseLoaderOrder } from "./settings.ts";
|
|
6
7
|
import { pickRandomWord } from "./words.ts";
|
|
7
8
|
const METER_INTERVAL_MS = 100, METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
8
9
|
const DEFAULT_WORKING_WORD = "Working…";
|
|
9
|
-
const PROMPT_IDS = new Set(["decorateUserPrompt", "borderColor", "promptIcon", "promptTimestamp"]);
|
|
10
|
-
const MARKER_IDS = new Set(["doneMarker", "doneMarkerIcon", "randomizeDoneMarker", "doneMarkerTokens"]);
|
|
10
|
+
const PROMPT_IDS = new Set(["decorateUserPrompt", "borderColor", "borderStyle", "promptIcon", "promptTimestamp"]);
|
|
11
|
+
const MARKER_IDS = new Set(["doneMarker", "doneMarkerIcon", "randomizeDoneMarker", "doneMarkerTokens", "doneMarkerInputs"]);
|
|
11
12
|
function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
|
|
12
13
|
|
|
13
14
|
/** Stateful, per-menu preview renderer that follows the active settings section. */
|
|
@@ -27,24 +28,33 @@ export class PreviewRenderer {
|
|
|
27
28
|
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false };
|
|
28
29
|
const decorations = { shimmer: values.shimmer !== false, tokenActivityMonitor: values.tokenActivityMonitor !== false };
|
|
29
30
|
const spinnerColor = (values.spinnerColorEnabled === false ? "accent" : values.spinnerColor === "border" || values.spinnerColor === "borderAccent" ? values.spinnerColor : "accent") as "accent" | "border" | "borderAccent";
|
|
30
|
-
const spinner = values.animatedSpinner ? this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs /
|
|
31
|
-
|
|
31
|
+
const spinner = values.animatedSpinner ? this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!) : "";
|
|
32
|
+
const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
|
|
33
|
+
if (isFullyDefaultAppearance(features, decorations)) return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, order);
|
|
32
34
|
const word = features.substituteDefaultMessage ? this.#word : DEFAULT_WORKING_WORD;
|
|
33
|
-
const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme, values.shimmerDirection === "Right to Left" ? "rtl" : "ltr") : this.#ctx.ui.theme.fg("text", word);
|
|
35
|
+
const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme, values.shimmerDirection === "Right to Left" ? "rtl" : "ltr", values.shimmerSpeed === "Slow" ? "slow" : values.shimmerSpeed === "Fast" ? "fast" : "normal") : this.#ctx.ui.theme.fg("text", word);
|
|
34
36
|
const meterColor = (values.meterColorEnabled === false ? "accent" : values.meterColor === "border" || values.meterColor === "borderAccent" ? values.meterColor : "accent") as "accent" | "border" | "borderAccent";
|
|
35
37
|
const meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed !== false)) : "";
|
|
36
|
-
return
|
|
38
|
+
return buildWorkingMessage(this.#ctx.ui.theme, {
|
|
39
|
+
spinner,
|
|
40
|
+
text: styledWord,
|
|
41
|
+
meter,
|
|
42
|
+
elapsed: features.elapsedTime ? formatElapsed(elapsedMs) : "",
|
|
43
|
+
tokens: features.outputTokens ? `↓ ${formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)))} tokens` : "",
|
|
44
|
+
}, order);
|
|
37
45
|
}
|
|
38
46
|
private promptPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
39
47
|
if (!values.decorateUserPrompt) return { lines: ["User prompt decoration is disabled."] };
|
|
40
48
|
|
|
41
49
|
const borderColor = values.borderColor;
|
|
50
|
+
const borderStyle = values.borderStyle;
|
|
42
51
|
const timestamp = values.promptTimestamp ? Date.now() : undefined;
|
|
43
52
|
const lines = buildPromptBoxLines("ping", timestamp, 70, this.#ctx.ui.theme, {
|
|
44
53
|
showIcon: values.promptIcon as boolean,
|
|
45
54
|
showTimestamp: values.promptTimestamp as boolean,
|
|
46
55
|
icon: values.useNerdFont ? "" : "π",
|
|
47
56
|
borderColor: borderColor === "accent" || borderColor === "border" || borderColor === "borderAccent" ? borderColor : "accent",
|
|
57
|
+
borderStyle: borderStyle === "double" || borderStyle === "single" || borderStyle === "rounded" || borderStyle === "heavy" ? borderStyle : "double",
|
|
48
58
|
});
|
|
49
59
|
return timestamp === undefined
|
|
50
60
|
? { lines }
|
|
@@ -55,7 +65,10 @@ export class PreviewRenderer {
|
|
|
55
65
|
const th = this.#ctx.ui.theme;
|
|
56
66
|
const icon = values.doneMarkerIcon ? `${values.useNerdFont ? "" : "π"} ` : "";
|
|
57
67
|
const word = (values.randomizeDoneMarker ? "Concocted" : "Worked") as string;
|
|
58
|
-
const
|
|
68
|
+
const details: string[] = [];
|
|
69
|
+
if (values.doneMarkerTokens) details.push("↓ 949 tokens");
|
|
70
|
+
if (values.doneMarkerInputs) details.push("2 mid-turn inputs");
|
|
71
|
+
const tail = details.length ? ` (${details.join(" · ")})` : "";
|
|
59
72
|
return { lines: ["", `${values.doneMarkerIcon ? th.fg("text", icon) : ""}${th.fg("dim", `${word} for 2m 52s${tail}`)}`, ""] };
|
|
60
73
|
}
|
|
61
74
|
}
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -3,12 +3,22 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works
|
|
|
3
3
|
|
|
4
4
|
export const PROMPT_BOX_TYPE = "pi-topping-prompt";
|
|
5
5
|
|
|
6
|
+
export type BorderStyle = "double" | "single" | "rounded" | "heavy";
|
|
7
|
+
|
|
8
|
+
const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
|
|
9
|
+
double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
|
|
10
|
+
single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
|
|
11
|
+
rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
|
|
12
|
+
heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
|
|
13
|
+
};
|
|
14
|
+
|
|
6
15
|
export interface PromptBoxDetails {
|
|
7
16
|
submittedAt?: number;
|
|
8
17
|
showIcon?: boolean;
|
|
9
18
|
showTimestamp?: boolean;
|
|
10
19
|
icon?: string;
|
|
11
20
|
borderColor?: "accent" | "border" | "borderAccent";
|
|
21
|
+
borderStyle?: BorderStyle;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
type PromptTheme = { fg(color: string, text: string): string };
|
|
@@ -23,6 +33,7 @@ export function buildPromptBoxLines(
|
|
|
23
33
|
): string[] {
|
|
24
34
|
if (width < 10) return [];
|
|
25
35
|
|
|
36
|
+
const g = BORDER_GLYPHS[options.borderStyle ?? "double"];
|
|
26
37
|
const border = (text: string) => theme.fg(options.borderColor ?? "accent", text);
|
|
27
38
|
const label = (text: string) => theme.fg("customMessageLabel", text);
|
|
28
39
|
const muted = (text: string) => theme.fg("dim", text);
|
|
@@ -41,7 +52,7 @@ export function buildPromptBoxLines(
|
|
|
41
52
|
const titleLength = visibleWidth(title);
|
|
42
53
|
const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
|
|
43
54
|
const topFill = Math.max(0, innerWidth - titleLength - timeLength);
|
|
44
|
-
const topLine = `${border(
|
|
55
|
+
const topLine = `${border(g.tl)}${icon ? `${border(`${g.h}${g.h} `)}${label(icon)}${border(" ")}` : ""}${border(g.h.repeat(topFill))}${time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : ""}${border(g.tr)}`;
|
|
45
56
|
const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
|
|
46
57
|
|
|
47
58
|
if (content) {
|
|
@@ -49,12 +60,12 @@ export function buildPromptBoxLines(
|
|
|
49
60
|
for (const rawLine of wrapTextWithAnsi(content, textWidth)) {
|
|
50
61
|
const displayLine = rawLine || " ";
|
|
51
62
|
const padded = `${displayLine}${" ".repeat(Math.max(0, textWidth - visibleWidth(displayLine)))}`;
|
|
52
|
-
const line = `${border(
|
|
63
|
+
const line = `${border(g.v)}${bodyText(` ${padded} `)}${border(g.v)}`;
|
|
53
64
|
lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
|
|
54
65
|
}
|
|
55
66
|
}
|
|
56
67
|
|
|
57
|
-
const bottomLine = `${border(
|
|
68
|
+
const bottomLine = `${border(g.bl)}${border(g.h.repeat(innerWidth))}${border(g.br)}`;
|
|
58
69
|
lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
|
|
59
70
|
return lines;
|
|
60
71
|
}
|
package/src/session.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
formatTokens,
|
|
19
19
|
isFullyDefaultAppearance,
|
|
20
20
|
shimmerString,
|
|
21
|
+
SPINNER_FRAME_MS,
|
|
21
22
|
SPINNER_FRAMES,
|
|
22
23
|
StreamingWordCounter,
|
|
23
24
|
} from "./format.ts";
|
|
@@ -41,7 +42,8 @@ const ELAPSED_INTERVAL_MS = 1_000;
|
|
|
41
42
|
export interface DoneEntryData {
|
|
42
43
|
word: string;
|
|
43
44
|
elapsedMs: number;
|
|
44
|
-
tokens
|
|
45
|
+
tokens?: number;
|
|
46
|
+
midTurnInputs?: number;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface SessionState {
|
|
@@ -55,6 +57,7 @@ export interface SessionState {
|
|
|
55
57
|
lastActivityMeterUpdate: number;
|
|
56
58
|
timer: ReturnType<typeof setInterval> | null;
|
|
57
59
|
busy: boolean;
|
|
60
|
+
midTurnInputs: number;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
export function makeFreshState(): SessionState {
|
|
@@ -69,6 +72,7 @@ export function makeFreshState(): SessionState {
|
|
|
69
72
|
lastActivityMeterUpdate: 0,
|
|
70
73
|
timer: null,
|
|
71
74
|
busy: false,
|
|
75
|
+
midTurnInputs: 0,
|
|
72
76
|
};
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -99,7 +103,8 @@ export class SessionManager {
|
|
|
99
103
|
this.#currentCtx = ctx;
|
|
100
104
|
if (!this.usable(ctx)) return;
|
|
101
105
|
|
|
102
|
-
this.resetTurn(Date.now());
|
|
106
|
+
if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
|
|
107
|
+
else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
|
|
103
108
|
if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
|
|
104
109
|
this.#pi.sendMessage<PromptBoxDetails>(
|
|
105
110
|
{
|
|
@@ -112,6 +117,7 @@ export class SessionManager {
|
|
|
112
117
|
showTimestamp: this.#settings.decorations.promptTimestamp,
|
|
113
118
|
icon: this.#settings.decorations.useNerdFont ? "" : "π",
|
|
114
119
|
borderColor: this.#settings.decorations.borderColor,
|
|
120
|
+
borderStyle: this.#settings.decorations.borderStyle,
|
|
115
121
|
},
|
|
116
122
|
},
|
|
117
123
|
{
|
|
@@ -195,6 +201,7 @@ export class SessionManager {
|
|
|
195
201
|
word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
|
|
196
202
|
elapsedMs,
|
|
197
203
|
tokens: this.#state.confirmTokens,
|
|
204
|
+
midTurnInputs: this.#state.midTurnInputs,
|
|
198
205
|
});
|
|
199
206
|
}
|
|
200
207
|
this.#currentCtx = null;
|
|
@@ -214,13 +221,19 @@ export class SessionManager {
|
|
|
214
221
|
this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
|
|
215
222
|
this.#pi.on("agent_settled", this.#onAgentSettled);
|
|
216
223
|
this.#pi.on("session_shutdown", this.#onSessionShutdown);
|
|
217
|
-
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) =>
|
|
218
|
-
entry.data
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
+
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => {
|
|
225
|
+
if (!entry.data) return undefined;
|
|
226
|
+
const features = this.#settings.features;
|
|
227
|
+
const details: string[] = [];
|
|
228
|
+
if (features.doneMarkerTokens && entry.data.tokens !== undefined) details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
|
|
229
|
+
if (features.doneMarkerInputs && entry.data.midTurnInputs) {
|
|
230
|
+
details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
|
|
231
|
+
}
|
|
232
|
+
const tail = details.length ? ` (${details.join(" · ")})` : "";
|
|
233
|
+
return new Text(
|
|
234
|
+
`${features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${tail}`)}`,
|
|
235
|
+
);
|
|
236
|
+
});
|
|
224
237
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
225
238
|
this.#pi.registerCommand("topping-settings", {
|
|
226
239
|
description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
|
|
@@ -236,14 +249,31 @@ export class SessionManager {
|
|
|
236
249
|
return event.source !== "extension" && !!event.text.trim() && !event.images?.length && !/^[\/!?:]/.test(event.text);
|
|
237
250
|
}
|
|
238
251
|
|
|
239
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Pi's Loader always prepends its indicator to the working message, so a spinner
|
|
254
|
+
* that is not the leading element has to be drawn inside the message instead.
|
|
255
|
+
*/
|
|
256
|
+
private spinnerInMessage(): boolean {
|
|
257
|
+
return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private spinnerColor(): "accent" | "border" | "borderAccent" {
|
|
240
261
|
const decorations = this.#settings.decorations;
|
|
241
|
-
|
|
262
|
+
return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private indicatorFingerprint(): string {
|
|
266
|
+
const decorations = this.#settings.decorations;
|
|
267
|
+
return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private applyIndicator(ctx: ExtensionContext): void {
|
|
271
|
+
if (!this.#settings.decorations.animatedSpinner || this.spinnerInMessage()) {
|
|
242
272
|
ctx.ui.setWorkingIndicator({ frames: [] });
|
|
243
273
|
return;
|
|
244
274
|
}
|
|
245
275
|
|
|
246
|
-
const color =
|
|
276
|
+
const color = this.spinnerColor();
|
|
247
277
|
if (color === "accent") {
|
|
248
278
|
ctx.ui.setWorkingIndicator(undefined);
|
|
249
279
|
return;
|
|
@@ -263,13 +293,14 @@ export class SessionManager {
|
|
|
263
293
|
|
|
264
294
|
const features = this.#settings.features;
|
|
265
295
|
const decorations = this.#settings.decorations;
|
|
266
|
-
|
|
296
|
+
let interval = decorations.shimmer
|
|
267
297
|
? SHIMMER_INTERVAL
|
|
268
298
|
: (decorations.tokenActivityMonitor || features.outputTokens)
|
|
269
299
|
? METER_INTERVAL_MS
|
|
270
300
|
: features.elapsedTime
|
|
271
301
|
? ELAPSED_INTERVAL_MS
|
|
272
302
|
: undefined;
|
|
303
|
+
if (this.spinnerInMessage()) interval = Math.min(interval ?? SPINNER_FRAME_MS, SPINNER_FRAME_MS);
|
|
273
304
|
if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
|
|
274
305
|
}
|
|
275
306
|
|
|
@@ -283,6 +314,7 @@ export class SessionManager {
|
|
|
283
314
|
state.activityMeter.reset();
|
|
284
315
|
state.rateTracker.reset();
|
|
285
316
|
state.lastActivityMeterUpdate = 0;
|
|
317
|
+
state.midTurnInputs = 0;
|
|
286
318
|
this.#counter.reset();
|
|
287
319
|
}
|
|
288
320
|
|
|
@@ -299,14 +331,21 @@ export class SessionManager {
|
|
|
299
331
|
state.activityMeter.push(rateToLevel(state.rateTracker.sample(total, now)));
|
|
300
332
|
state.lastActivityMeterUpdate = now;
|
|
301
333
|
}
|
|
334
|
+
const spinner = this.spinnerInMessage()
|
|
335
|
+
? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
|
|
336
|
+
: "";
|
|
302
337
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
303
|
-
ctx.ui.setWorkingMessage(
|
|
338
|
+
ctx.ui.setWorkingMessage(
|
|
339
|
+
spinner
|
|
340
|
+
? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
|
|
341
|
+
: undefined,
|
|
342
|
+
);
|
|
304
343
|
return;
|
|
305
344
|
}
|
|
306
345
|
|
|
307
346
|
const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
|
|
308
347
|
const styled = decorations.shimmer
|
|
309
|
-
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection)
|
|
348
|
+
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed)
|
|
310
349
|
: ctx.ui.theme.fg("text", word);
|
|
311
350
|
const meter = decorations.tokenActivityMonitor
|
|
312
351
|
? state.activityMeter.render((level, char) =>
|
|
@@ -322,11 +361,14 @@ export class SessionManager {
|
|
|
322
361
|
ctx.ui.setWorkingMessage(
|
|
323
362
|
buildWorkingMessage(
|
|
324
363
|
ctx.ui.theme,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
364
|
+
{
|
|
365
|
+
spinner,
|
|
366
|
+
text: styled,
|
|
367
|
+
meter,
|
|
368
|
+
elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
|
|
369
|
+
tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
|
|
370
|
+
},
|
|
371
|
+
this.#settings.loaderOrder,
|
|
330
372
|
),
|
|
331
373
|
);
|
|
332
374
|
}
|
|
@@ -337,7 +379,7 @@ export class SessionManager {
|
|
|
337
379
|
return;
|
|
338
380
|
}
|
|
339
381
|
|
|
340
|
-
const before =
|
|
382
|
+
const before = this.indicatorFingerprint();
|
|
341
383
|
const preview = new PreviewRenderer(ctx);
|
|
342
384
|
const result = await showMenu<Record<string, boolean | string>>(ctx, {
|
|
343
385
|
title: "Pi Topping: Settings",
|
|
@@ -356,12 +398,7 @@ export class SessionManager {
|
|
|
356
398
|
}
|
|
357
399
|
|
|
358
400
|
this.#settings = updatedSettings;
|
|
359
|
-
if (
|
|
360
|
-
before !==
|
|
361
|
-
`${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`
|
|
362
|
-
) {
|
|
363
|
-
this.applyIndicator(ctx);
|
|
364
|
-
}
|
|
401
|
+
if (before !== this.indicatorFingerprint()) this.applyIndicator(ctx);
|
|
365
402
|
this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
|
|
366
403
|
if (this.#state.busy) {
|
|
367
404
|
this.stopTimer();
|
package/src/settings.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
|
|
4
5
|
import type { MenuSection } from "./menu.ts";
|
|
5
6
|
|
|
6
7
|
export interface DecoratorSettings {
|
|
@@ -9,12 +10,16 @@ export interface DecoratorSettings {
|
|
|
9
10
|
shimmer: boolean;
|
|
10
11
|
shimmerDirection: "ltr" | "rtl";
|
|
11
12
|
shimmerDirectionEnabled: boolean;
|
|
13
|
+
shimmerSpeed: "slow" | "normal" | "fast";
|
|
14
|
+
shimmerSpeedEnabled: boolean;
|
|
12
15
|
tokenActivityMonitor: boolean;
|
|
13
16
|
meterDirection: "ltr" | "rtl";
|
|
14
17
|
meterDirectionEnabled: boolean;
|
|
15
18
|
decorateUserPrompt: boolean;
|
|
16
19
|
borderColor: "accent" | "border" | "borderAccent";
|
|
17
20
|
borderColorEnabled: boolean;
|
|
21
|
+
borderStyle: "double" | "single" | "rounded" | "heavy";
|
|
22
|
+
borderStyleEnabled: boolean;
|
|
18
23
|
spinnerColor: "accent" | "border" | "borderAccent";
|
|
19
24
|
spinnerColorEnabled: boolean;
|
|
20
25
|
meterColor: "accent" | "border" | "borderAccent";
|
|
@@ -32,14 +37,43 @@ export interface DecoratorSettings {
|
|
|
32
37
|
doneMarkerIcon: boolean;
|
|
33
38
|
randomizeDoneMarker: boolean;
|
|
34
39
|
doneMarkerTokens: boolean;
|
|
40
|
+
doneMarkerInputs: boolean;
|
|
35
41
|
};
|
|
42
|
+
loaderOrder: LoaderElement[];
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
39
|
-
decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, tokenActivityMonitor: true, meterDirection: "ltr", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "accent", borderColorEnabled: true, spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, promptIcon: true, promptTimestamp: true, useNerdFont: true },
|
|
40
|
-
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true },
|
|
46
|
+
decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "ltr", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "accent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, promptIcon: true, promptTimestamp: true, useNerdFont: true },
|
|
47
|
+
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
|
|
48
|
+
loaderOrder: [...DEFAULT_LOADER_ORDER],
|
|
41
49
|
};
|
|
42
50
|
|
|
51
|
+
/** Menu key carrying the loader element order as a comma-joined list of element ids. */
|
|
52
|
+
export const LOADER_ORDER_ID = "loaderOrder";
|
|
53
|
+
const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
|
|
54
|
+
spinner: "Animated spinner",
|
|
55
|
+
text: "“Working” text",
|
|
56
|
+
meter: "Token activity monitor",
|
|
57
|
+
elapsed: "Elapsed time",
|
|
58
|
+
tokens: "Output tokens",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize an element order from disk or from the menu, tolerating hand edits and
|
|
63
|
+
* version skew: unknown entries and duplicates are dropped, missing elements are
|
|
64
|
+
* appended in their default positions.
|
|
65
|
+
*/
|
|
66
|
+
export function parseLoaderOrder(value: unknown): LoaderElement[] {
|
|
67
|
+
const entries = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
|
|
68
|
+
const order: LoaderElement[] = [];
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const element = typeof entry === "string" ? entry.trim() as LoaderElement : undefined;
|
|
71
|
+
if (element && DEFAULT_LOADER_ORDER.includes(element) && !order.includes(element)) order.push(element);
|
|
72
|
+
}
|
|
73
|
+
for (const element of DEFAULT_LOADER_ORDER) if (!order.includes(element)) order.push(element);
|
|
74
|
+
return order;
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
|
|
44
78
|
function isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
45
79
|
function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, parsed: unknown): T {
|
|
@@ -49,15 +83,20 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
|
|
|
49
83
|
if (!(key in merged)) continue;
|
|
50
84
|
if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
|
|
51
85
|
if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) (merged as Record<string, boolean | string>)[key] = value;
|
|
86
|
+
if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) (merged as Record<string, boolean | string>)[key] = value;
|
|
52
87
|
if (key.endsWith("Color") && (value === "accent" || value === "border" || value === "borderAccent")) (merged as Record<string, boolean | string>)[key] = value;
|
|
88
|
+
if (key === "borderStyle" && (value === "double" || value === "single" || value === "rounded" || value === "heavy")) (merged as Record<string, boolean | string>)[key] = value;
|
|
53
89
|
}
|
|
54
90
|
return merged;
|
|
55
91
|
}
|
|
56
92
|
|
|
57
|
-
/** Map a persisted setting value to its menu cycle label (directions only; colors pass through). */
|
|
93
|
+
/** Map a persisted setting value to its menu cycle label (directions and speeds only; colors pass through). */
|
|
58
94
|
function toCycleValue(stored: unknown): string {
|
|
59
95
|
if (stored === "ltr") return "Left to Right";
|
|
60
96
|
if (stored === "rtl") return "Right to Left";
|
|
97
|
+
if (stored === "slow") return "Slow";
|
|
98
|
+
if (stored === "normal") return "Normal";
|
|
99
|
+
if (stored === "fast") return "Fast";
|
|
61
100
|
return String(stored);
|
|
62
101
|
}
|
|
63
102
|
|
|
@@ -65,10 +104,13 @@ function toCycleValue(stored: unknown): string {
|
|
|
65
104
|
function fromCycleValue(value: string): string {
|
|
66
105
|
if (value === "Left to Right") return "ltr";
|
|
67
106
|
if (value === "Right to Left") return "rtl";
|
|
107
|
+
if (value === "Slow") return "slow";
|
|
108
|
+
if (value === "Normal") return "normal";
|
|
109
|
+
if (value === "Fast") return "fast";
|
|
68
110
|
return value;
|
|
69
111
|
}
|
|
70
112
|
|
|
71
|
-
type MenuSectionName = "User Prompt" | "Working Loader
|
|
113
|
+
type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
|
|
72
114
|
type DecorationSettings = DecoratorSettings["decorations"];
|
|
73
115
|
type FeatureSettings = DecoratorSettings["features"];
|
|
74
116
|
type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
|
|
@@ -79,23 +121,26 @@ type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
|
|
|
79
121
|
export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
80
122
|
{ id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" },
|
|
81
123
|
{ id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "border" },
|
|
124
|
+
{ id: "borderStyle", label: "Border style", section: "User Prompt", group: "decorations", key: "borderStyle", cycleValues: ["double", "single", "rounded", "heavy"], cycleEnabledBy: "borderStyleEnabled", cycleDisabledValue: "double" },
|
|
82
125
|
{ id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
|
|
83
126
|
{ id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" },
|
|
84
|
-
{ id: "animatedSpinner", label: "Animated spinner", section: "Working Loader
|
|
85
|
-
{ id: "spinnerColor", label: "Animated spinner color", section: "Working Loader
|
|
86
|
-
{ id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "Working Loader
|
|
87
|
-
{ id: "shimmer", label: "Text shimmer", section: "Working Loader
|
|
88
|
-
{ id: "shimmerDirection", label: "Text shimmer direction", section: "Working Loader
|
|
89
|
-
{ id: "
|
|
90
|
-
{ id: "
|
|
91
|
-
{ id: "
|
|
92
|
-
{ id: "
|
|
93
|
-
{ id: "
|
|
94
|
-
{ id: "
|
|
127
|
+
{ id: "animatedSpinner", label: "Animated spinner", section: "“Working” Loader", group: "decorations", key: "animatedSpinner" },
|
|
128
|
+
{ id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
|
|
129
|
+
{ id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "“Working” Loader", group: "features", key: "substituteDefaultMessage" },
|
|
130
|
+
{ id: "shimmer", label: "Text shimmer", section: "“Working” Loader", group: "decorations", key: "shimmer" },
|
|
131
|
+
{ id: "shimmerDirection", label: "Text shimmer direction", section: "“Working” Loader", group: "decorations", key: "shimmerDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "Left to Right" },
|
|
132
|
+
{ id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["Slow", "Normal", "Fast"], cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "Normal" },
|
|
133
|
+
{ id: "tokenActivityMonitor", label: "Token activity monitor", section: "“Working” Loader", group: "decorations", key: "tokenActivityMonitor" },
|
|
134
|
+
{ id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
|
|
135
|
+
{ id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Left to Right" },
|
|
136
|
+
{ id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
|
|
137
|
+
{ id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
|
|
138
|
+
{ id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" },
|
|
95
139
|
{ id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
|
|
96
140
|
{ id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
|
|
97
141
|
{ id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
|
|
98
142
|
{ id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
|
|
143
|
+
{ id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
|
|
99
144
|
{ id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
|
|
100
145
|
];
|
|
101
146
|
|
|
@@ -119,18 +164,34 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
|
|
|
119
164
|
case "meterColor":
|
|
120
165
|
if (stored === "accent" || stored === "border" || stored === "borderAccent") decorations[key] = stored;
|
|
121
166
|
return;
|
|
167
|
+
case "borderStyle":
|
|
168
|
+
if (stored === "double" || stored === "single" || stored === "rounded" || stored === "heavy") decorations[key] = stored;
|
|
169
|
+
return;
|
|
122
170
|
case "shimmerDirection":
|
|
123
171
|
case "meterDirection":
|
|
124
172
|
if (stored === "ltr" || stored === "rtl") decorations[key] = stored;
|
|
125
173
|
return;
|
|
174
|
+
case "shimmerSpeed":
|
|
175
|
+
if (stored === "slow" || stored === "normal" || stored === "fast") decorations[key] = stored;
|
|
176
|
+
return;
|
|
126
177
|
default:
|
|
127
178
|
// Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here.
|
|
128
179
|
throw new Error(`Unhandled cycle setting: ${String(key)}`);
|
|
129
180
|
}
|
|
130
181
|
}
|
|
131
182
|
|
|
183
|
+
function toggleSection(title: MenuSectionName, settings: DecoratorSettings): MenuSection {
|
|
184
|
+
return { title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) };
|
|
185
|
+
}
|
|
186
|
+
|
|
132
187
|
export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
|
|
133
|
-
return [
|
|
188
|
+
return [
|
|
189
|
+
toggleSection("User Prompt", settings),
|
|
190
|
+
toggleSection("“Working” Loader", settings),
|
|
191
|
+
{ title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
|
|
192
|
+
toggleSection("Completion Marker", settings),
|
|
193
|
+
toggleSection("Options", settings),
|
|
194
|
+
];
|
|
134
195
|
}
|
|
135
196
|
|
|
136
197
|
export function applyMenuResult(settings: DecoratorSettings, values: Record<string, boolean | string>): DecoratorSettings {
|
|
@@ -147,6 +208,7 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
|
|
|
147
208
|
next.features[entry.key] = value;
|
|
148
209
|
}
|
|
149
210
|
}
|
|
211
|
+
if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]);
|
|
150
212
|
return next;
|
|
151
213
|
}
|
|
152
214
|
|
|
@@ -154,13 +216,15 @@ export function loadSettings(): DecoratorSettings {
|
|
|
154
216
|
try {
|
|
155
217
|
const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
|
|
156
218
|
if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
|
|
157
|
-
const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features) };
|
|
158
|
-
const gated: readonly (readonly ["borderColor" | "spinnerColor" | "meterColor" | "shimmerDirection" | "meterDirection", "borderColorEnabled" | "spinnerColorEnabled" | "meterColorEnabled" | "shimmerDirectionEnabled" | "meterDirectionEnabled", string])[] = [
|
|
219
|
+
const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder) };
|
|
220
|
+
const gated: readonly (readonly ["borderColor" | "borderStyle" | "spinnerColor" | "meterColor" | "shimmerDirection" | "meterDirection" | "shimmerSpeed", "borderColorEnabled" | "borderStyleEnabled" | "spinnerColorEnabled" | "meterColorEnabled" | "shimmerDirectionEnabled" | "meterDirectionEnabled" | "shimmerSpeedEnabled", string])[] = [
|
|
159
221
|
["borderColor", "borderColorEnabled", "border"],
|
|
222
|
+
["borderStyle", "borderStyleEnabled", "double"],
|
|
160
223
|
["spinnerColor", "spinnerColorEnabled", "accent"],
|
|
161
224
|
["meterColor", "meterColorEnabled", "accent"],
|
|
162
225
|
["shimmerDirection", "shimmerDirectionEnabled", "ltr"],
|
|
163
226
|
["meterDirection", "meterDirectionEnabled", "ltr"],
|
|
227
|
+
["shimmerSpeed", "shimmerSpeedEnabled", "normal"],
|
|
164
228
|
];
|
|
165
229
|
for (const [key, enabledKey, resetValue] of gated) {
|
|
166
230
|
if (!settings.decorations[enabledKey]) (settings.decorations as unknown as Record<string, string>)[key] = resetValue;
|