@underactive/pi-topping 0.1.0 → 0.2.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 +5 -2
- package/activity-meter.ts +31 -5
- package/format.ts +73 -5
- package/index.ts +5 -462
- package/menu.ts +89 -61
- package/package.json +3 -2
- package/preview.ts +47 -0
- package/session.ts +43 -0
- package/settings.ts +116 -7
- package/words.ts +199 -187
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
|
|
|
11
11
|
- Shows an elapsed timer (`Xm YYs`) that counts up from when you submitted the prompt.
|
|
12
12
|
- Shows a live output-token estimate (`↓ N tokens`), reconciled against the real usage total once each assistant message finishes.
|
|
13
13
|
- Resets everything — word, timer, token count — on each new prompt, and restores Pi's normal loader between turns.
|
|
14
|
+
- Leaves a durable completion marker in the transcript after each finished turn, e.g. `π Baked for 6m 41s` — a fresh random word and the total time for that turn, rendered with `π` in the theme's primary text color and the rest dimmed. It's TUI-only display content and never enters LLM context.
|
|
14
15
|
|
|
15
16
|
## Activity meter
|
|
16
17
|
|
|
@@ -30,10 +31,12 @@ Run `/topping-settings` (TUI only) to toggle things on or off:
|
|
|
30
31
|
║ ▸ [■] Animated spinner ON ║
|
|
31
32
|
║ [■] "Working..." text shimmer ON ║
|
|
32
33
|
║ [■] Token activity monitor ON ║
|
|
33
|
-
╟─
|
|
34
|
+
╟─ Options ─────────────────────────────────────────╢
|
|
34
35
|
║ [■] Substitute Pi's "Working..." message ON ║
|
|
35
36
|
║ [■] Elapsed time since prompt ON ║
|
|
36
37
|
║ [■] Show output tokens ON ║
|
|
38
|
+
║ [■] Show completion marker ON ║
|
|
39
|
+
║ [ ] Scrolling: Right → Left OFF ║
|
|
37
40
|
╟───────────────────────────────────────────────────╢
|
|
38
41
|
║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
|
|
39
42
|
╚═══════════════════════════════════════════════════╝
|
|
@@ -41,7 +44,7 @@ Run `/topping-settings` (TUI only) to toggle things on or off:
|
|
|
41
44
|
|
|
42
45
|
**Decorations** — animated spinner, word shimmer, activity meter.
|
|
43
46
|
|
|
44
|
-
**
|
|
47
|
+
**Options** — whether to substitute the random word at all, the elapsed timer, the token count, the completion marker, and the meter scrolling direction.
|
|
45
48
|
|
|
46
49
|
The menu has a live preview so you can see changes as you make them. Everything is on by default, so nothing changes unless you open the menu. Use `↑↓` to move, `␣` to toggle, `⏎` to save, `esc` to cancel. Settings are saved to `~/.pi/agent/pi-topping/settings.json`.
|
|
47
50
|
|
package/activity-meter.ts
CHANGED
|
@@ -42,6 +42,7 @@ export class TokRateTracker {
|
|
|
42
42
|
#lastTime = 0;
|
|
43
43
|
#rate = 0;
|
|
44
44
|
#hasSample = false;
|
|
45
|
+
#pendingTokens = 0;
|
|
45
46
|
|
|
46
47
|
sample(totalTokens: number, now: number): number {
|
|
47
48
|
if (!this.#hasSample) {
|
|
@@ -52,12 +53,18 @@ export class TokRateTracker {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
const elapsedSeconds = (now - this.#lastTime) / 1_000;
|
|
55
|
-
if (elapsedSeconds <= 0)
|
|
56
|
+
if (elapsedSeconds <= 0) {
|
|
57
|
+
this.#pendingTokens += Math.max(0, totalTokens - this.#lastTotal);
|
|
58
|
+
this.#lastTotal = totalTokens;
|
|
59
|
+
return this.#rate;
|
|
60
|
+
}
|
|
56
61
|
|
|
57
|
-
const
|
|
62
|
+
const totalDelta = this.#pendingTokens + Math.max(0, totalTokens - this.#lastTotal);
|
|
63
|
+
const instantRate = totalDelta / elapsedSeconds;
|
|
58
64
|
this.#rate = EMA_ALPHA * instantRate + (1 - EMA_ALPHA) * this.#rate;
|
|
59
65
|
this.#lastTotal = totalTokens;
|
|
60
66
|
this.#lastTime = now;
|
|
67
|
+
this.#pendingTokens = 0;
|
|
61
68
|
return this.#rate;
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -66,16 +73,35 @@ export class TokRateTracker {
|
|
|
66
73
|
this.#lastTime = 0;
|
|
67
74
|
this.#rate = 0;
|
|
68
75
|
this.#hasSample = false;
|
|
76
|
+
this.#pendingTokens = 0;
|
|
69
77
|
}
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
/** Eight-column, left-to-right
|
|
80
|
+
/** Eight-column scrolling activity meter, scrolling either left-to-right or right-to-left. */
|
|
73
81
|
export class ActivityMeter {
|
|
74
82
|
#levels: ActivityMeterLevel[] = Array<ActivityMeterLevel>(WIDTH).fill(0);
|
|
83
|
+
#direction: "ltr" | "rtl";
|
|
84
|
+
|
|
85
|
+
constructor(direction: "ltr" | "rtl" = "ltr") {
|
|
86
|
+
this.#direction = direction;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setDirection(direction: "ltr" | "rtl"): void {
|
|
90
|
+
if (direction !== this.#direction) {
|
|
91
|
+
this.#direction = direction;
|
|
92
|
+
// Reverse the existing data so the visual flow instantly flips.
|
|
93
|
+
this.#levels.reverse();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
75
96
|
|
|
76
97
|
push(level: ActivityMeterLevel): void {
|
|
77
|
-
this.#
|
|
78
|
-
|
|
98
|
+
if (this.#direction === "rtl") {
|
|
99
|
+
this.#levels.shift();
|
|
100
|
+
this.#levels.push(level);
|
|
101
|
+
} else {
|
|
102
|
+
this.#levels.pop();
|
|
103
|
+
this.#levels.unshift(level);
|
|
104
|
+
}
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
render(colorize?: CellColorizer): string {
|
package/format.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
1
3
|
/** Format an output-token count for the working indicator. */
|
|
2
4
|
export function formatTokens(count: number): string {
|
|
3
5
|
if (count < 1000) return count.toString();
|
|
4
6
|
const UNITS = [
|
|
5
7
|
{ threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
|
|
6
|
-
{ threshold:
|
|
8
|
+
{ threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
|
|
7
9
|
{ threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
|
|
10
|
+
{ threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
|
|
11
|
+
{ threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
|
|
8
12
|
];
|
|
9
13
|
const unit = UNITS.find(u => count < u.threshold);
|
|
10
14
|
if (unit) {
|
|
@@ -13,15 +17,79 @@ export function formatTokens(count: number): string {
|
|
|
13
17
|
: `${Math.round(count / unit.divisor)}`;
|
|
14
18
|
return `${value}${unit.suffix}`;
|
|
15
19
|
}
|
|
16
|
-
return `${
|
|
20
|
+
return `${(count / 1_000_000_000).toFixed(1)}B`;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
/** Format elapsed milliseconds as
|
|
23
|
+
/** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
|
|
20
24
|
export function formatElapsed(ms: number): string {
|
|
21
|
-
|
|
25
|
+
let totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
26
|
+
const parts: string[] = [];
|
|
27
|
+
|
|
28
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
29
|
+
totalSeconds -= days * 86400;
|
|
30
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
31
|
+
totalSeconds -= hours * 3600;
|
|
22
32
|
const minutes = Math.floor(totalSeconds / 60);
|
|
23
33
|
const seconds = totalSeconds % 60;
|
|
24
|
-
|
|
34
|
+
|
|
35
|
+
if (days > 0) parts.push(`${days}d`);
|
|
36
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
37
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
38
|
+
parts.push(`${seconds}s`);
|
|
39
|
+
|
|
40
|
+
return parts.join(" ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Codex-style light-sweep shimmer using the active Pi theme. */
|
|
44
|
+
export function isFullyDefaultAppearance(features: {
|
|
45
|
+
substituteDefaultMessage: boolean;
|
|
46
|
+
elapsedTime: boolean;
|
|
47
|
+
outputTokens: boolean;
|
|
48
|
+
}, decorations: { shimmer: boolean; tokenActivityMonitor: boolean }): boolean {
|
|
49
|
+
return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Assemble the styled word, meter, and optional detail fields. */
|
|
53
|
+
export function buildWorkingMessage(
|
|
54
|
+
theme: Pick<Theme, "fg">,
|
|
55
|
+
word: string,
|
|
56
|
+
frame: string,
|
|
57
|
+
elapsed: string,
|
|
58
|
+
tokens: string,
|
|
59
|
+
features: { elapsedTime: boolean; outputTokens: boolean },
|
|
60
|
+
): string {
|
|
61
|
+
const parts = [word, frame].filter(Boolean);
|
|
62
|
+
const details = [features.elapsedTime ? elapsed : "", features.outputTokens ? `↓ ${tokens} tokens` : ""].filter(Boolean);
|
|
63
|
+
if (details.length) parts.push(theme.fg("dim", `(${details.join(" · ")})`));
|
|
64
|
+
return parts.join(" ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function shimmerString(
|
|
68
|
+
text: string,
|
|
69
|
+
elapsedMs: number,
|
|
70
|
+
theme: Pick<Theme, "fg" | "bold">,
|
|
71
|
+
): string {
|
|
72
|
+
const chars = [...text];
|
|
73
|
+
if (chars.length === 0) return "";
|
|
74
|
+
const SHIMMER_SWEEP_S = 2.0;
|
|
75
|
+
const SHIMMER_BAND_HALF = 5.0;
|
|
76
|
+
const SHIMMER_PADDING = 10;
|
|
77
|
+
const period = chars.length + SHIMMER_PADDING * 2;
|
|
78
|
+
const elapsedS = elapsedMs / 1000;
|
|
79
|
+
const pos = ((elapsedS % SHIMMER_SWEEP_S) / SHIMMER_SWEEP_S) * period;
|
|
80
|
+
|
|
81
|
+
return chars
|
|
82
|
+
.map((ch, i) => {
|
|
83
|
+
const dist = Math.abs(i + SHIMMER_PADDING - pos);
|
|
84
|
+
const intensity =
|
|
85
|
+
dist <= SHIMMER_BAND_HALF
|
|
86
|
+
? 0.5 * (1 + Math.cos((Math.PI * dist) / SHIMMER_BAND_HALF))
|
|
87
|
+
: 0;
|
|
88
|
+
if (intensity > 0.6) return theme.bold(theme.fg("text", ch));
|
|
89
|
+
if (intensity > 0.15) return theme.fg("muted", ch);
|
|
90
|
+
return theme.fg("dim", ch);
|
|
91
|
+
})
|
|
92
|
+
.join("");
|
|
25
93
|
}
|
|
26
94
|
|
|
27
95
|
/**
|
package/index.ts
CHANGED
|
@@ -1,464 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Replaces pi's default "Working..." loader text with a shimmering,
|
|
5
|
-
* randomized activity word plus live elapsed-since-prompt time and a live
|
|
6
|
-
* output-token estimate, e.g.:
|
|
7
|
-
*
|
|
8
|
-
* Cerebrating… (4m 46s · ↓ 17k tokens)
|
|
9
|
-
*
|
|
10
|
-
* - A new random word is picked on every user prompt and on every tool
|
|
11
|
-
* execution start (so long tool loops keep feeling "alive").
|
|
12
|
-
* - Elapsed time counts up continuously from the moment the prompt is
|
|
13
|
-
* submitted, uninterrupted by tool calls.
|
|
14
|
-
* - Token count is a live streaming-delta word-count estimate that gets
|
|
15
|
-
* reconciled against the authoritative usage total at each assistant
|
|
16
|
-
* message_end.
|
|
17
|
-
*
|
|
18
|
-
* All of the above are independently toggleable via `/topping-settings`
|
|
19
|
-
* (see settings.ts and menu.ts); every toggle defaults to enabled.
|
|
20
|
-
*/
|
|
1
|
+
/** pi-topping extension entry point. */
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { SessionManager } from "./session.ts";
|
|
21
4
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
|
|
25
|
-
import { showMenu, type MenuSection } from "./menu.ts";
|
|
26
|
-
import { type DecoratorSettings, loadSettings, saveSettings } from "./settings.ts";
|
|
27
|
-
import { WORDS } from "./words.ts";
|
|
28
|
-
|
|
29
|
-
function pickRandomWord(): string {
|
|
30
|
-
const word = WORDS[Math.floor(Math.random() * WORDS.length)]!;
|
|
31
|
-
return `${word}\u2026`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Grayscale shimmer (ported from pi-synthwave-statusline's shimmerString)
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
const SHIMMER_INTERVAL = 50; // ms (20fps; activity meter updates every 100ms)
|
|
39
|
-
const ACTIVITY_METER_INTERVAL_MS = 100;
|
|
40
|
-
const SHIMMER_SWEEP_S = 2.0; // seconds per full L->R sweep
|
|
41
|
-
const SHIMMER_BAND_HALF = 5.0; // half-width of highlight band in chars
|
|
42
|
-
const SHIMMER_PADDING = 10; // extra sweep padding so band enters/exits smoothly
|
|
43
|
-
|
|
44
|
-
// Placeholder word shown in place of the randomized activity word when
|
|
45
|
-
// "Substitute Pi's Working... message" is off. Decorations (shimmer, meter)
|
|
46
|
-
// and features (elapsed time, output tokens) still apply to this placeholder
|
|
47
|
-
// independently -- that toggle only controls which *word* is shown, not
|
|
48
|
-
// whether the other toggles have any effect.
|
|
49
|
-
const DEFAULT_WORKING_WORD = "Working\u2026";
|
|
50
|
-
|
|
51
|
-
// Settings-menu live preview: a self-contained simulation (fake word, fake
|
|
52
|
-
// spinner, fake breathing activity-meter rate) driving the same rendering
|
|
53
|
-
// code paths as the real working message, so the preview always matches
|
|
54
|
-
// reality exactly.
|
|
55
|
-
const PREVIEW_SPINNER_FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
|
|
56
|
-
const PREVIEW_SPINNER_INTERVAL_MS = 80;
|
|
57
|
-
const PREVIEW_TOKEN_RATE_PER_SEC = 28;
|
|
58
|
-
// The meter's simulated rate oscillates smoothly between idle and full
|
|
59
|
-
// (rather than holding a constant value) so it's visibly animated instead of
|
|
60
|
-
// scrolling in a flat, unchanging line. One full idle->full->idle breath
|
|
61
|
-
// takes PREVIEW_METER_PERIOD_MS; PREVIEW_METER_PEAK_RATE sits comfortably
|
|
62
|
-
// above rateToLevel's top threshold (40 tok/s) so the meter reaches FULL.
|
|
63
|
-
const PREVIEW_METER_PERIOD_MS = 2400;
|
|
64
|
-
const PREVIEW_METER_PEAK_RATE = 46;
|
|
65
|
-
|
|
66
|
-
/** Smoothly oscillating tok/s value used to animate the settings-menu preview meter. */
|
|
67
|
-
function previewMeterRate(elapsedMs: number): number {
|
|
68
|
-
const wave = (1 - Math.cos((2 * Math.PI * elapsedMs) / PREVIEW_METER_PERIOD_MS)) / 2; // 0..1, eased
|
|
69
|
-
return wave * PREVIEW_METER_PEAK_RATE;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* True only when every toggle that could customize the working message is
|
|
74
|
-
* off, i.e. the message would look exactly like Pi's untouched default. In
|
|
75
|
-
* that case (and only that case) we fully restore the default instead of
|
|
76
|
-
* building a message that merely happens to look the same.
|
|
77
|
-
*/
|
|
78
|
-
function isFullyDefaultAppearance(
|
|
79
|
-
substituteDefaultMessage: boolean,
|
|
80
|
-
shimmer: boolean,
|
|
81
|
-
tokenActivityMonitor: boolean,
|
|
82
|
-
elapsedTime: boolean,
|
|
83
|
-
outputTokens: boolean,
|
|
84
|
-
): boolean {
|
|
85
|
-
return !substituteDefaultMessage && !shimmer && !tokenActivityMonitor && !elapsedTime && !outputTokens;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Codex-style light-sweep shimmer using the active Pi theme. */
|
|
89
|
-
function shimmerString(text: string, elapsedMs: number, theme: ExtensionContext["ui"]["theme"]): string {
|
|
90
|
-
const chars = [...text];
|
|
91
|
-
if (chars.length === 0) return "";
|
|
92
|
-
const period = chars.length + SHIMMER_PADDING * 2;
|
|
93
|
-
const elapsedS = elapsedMs / 1000;
|
|
94
|
-
const pos = ((elapsedS % SHIMMER_SWEEP_S) / SHIMMER_SWEEP_S) * period;
|
|
95
|
-
|
|
96
|
-
return (
|
|
97
|
-
chars
|
|
98
|
-
.map((ch, i) => {
|
|
99
|
-
const dist = Math.abs(i + SHIMMER_PADDING - pos);
|
|
100
|
-
const intensity =
|
|
101
|
-
dist <= SHIMMER_BAND_HALF
|
|
102
|
-
? 0.5 * (1 + Math.cos((Math.PI * dist) / SHIMMER_BAND_HALF))
|
|
103
|
-
: 0;
|
|
104
|
-
if (intensity > 0.6) return theme.bold(theme.fg("text", ch));
|
|
105
|
-
if (intensity > 0.15) return theme.fg("muted", ch);
|
|
106
|
-
return theme.fg("dim", ch);
|
|
107
|
-
})
|
|
108
|
-
.join("")
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
|
-
// Extension
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
interface SessionState {
|
|
117
|
-
startTime: number;
|
|
118
|
-
currentWord: string;
|
|
119
|
-
confirmTokens: number;
|
|
120
|
-
liveTokens: number;
|
|
121
|
-
shimmerOrigin: number;
|
|
122
|
-
activityMeter: ActivityMeter;
|
|
123
|
-
rateTracker: TokRateTracker;
|
|
124
|
-
lastActivityMeterUpdate: number;
|
|
125
|
-
timer: ReturnType<typeof setInterval> | null;
|
|
126
|
-
busy: boolean;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function makeFreshState(): SessionState {
|
|
130
|
-
return {
|
|
131
|
-
startTime: 0,
|
|
132
|
-
currentWord: "",
|
|
133
|
-
confirmTokens: 0,
|
|
134
|
-
liveTokens: 0,
|
|
135
|
-
shimmerOrigin: 0,
|
|
136
|
-
activityMeter: new ActivityMeter(),
|
|
137
|
-
rateTracker: new TokRateTracker(),
|
|
138
|
-
lastActivityMeterUpdate: 0,
|
|
139
|
-
timer: null,
|
|
140
|
-
busy: false,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function cloneSettings(settings: DecoratorSettings): DecoratorSettings {
|
|
145
|
-
return {
|
|
146
|
-
decorations: { ...settings.decorations },
|
|
147
|
-
features: { ...settings.features },
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Applies a flat `{ id: boolean }` menu result onto a nested settings
|
|
153
|
-
* section in place, using each section's own key names as the source of
|
|
154
|
-
* truth. A key only gets updated if `values` actually contains it (so a
|
|
155
|
-
* partial/incomplete `values` object never zeroes out unrelated keys) --
|
|
156
|
-
* this preserves the exact semantics of the previous manual `??` chain.
|
|
157
|
-
*
|
|
158
|
-
* This only works because every settings leaf is a `boolean` and every
|
|
159
|
-
* `MenuItem.id` is spelled identically to its corresponding settings key.
|
|
160
|
-
* If a future toggle needs a non-boolean value, `menu.ts`'s
|
|
161
|
-
* `MenuItem.value: boolean` would need to change first.
|
|
162
|
-
*/
|
|
163
|
-
function applyMenuValues<T extends Record<string, boolean>>(target: T, values: Record<string, boolean>): void {
|
|
164
|
-
for (const key of Object.keys(target) as (keyof T & string)[]) {
|
|
165
|
-
const incoming = values[key];
|
|
166
|
-
if (incoming !== undefined) {
|
|
167
|
-
target[key] = incoming as T[keyof T & string];
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Pi extension install hook.
|
|
174
|
-
*
|
|
175
|
-
* Registers event handlers on the Pi lifecycle to replace the default
|
|
176
|
-
* "Working..." indicator with a shimmering activity word, live elapsed
|
|
177
|
-
* time, and an output-token rate activity meter -- gated by persisted
|
|
178
|
-
* settings configurable via `/topping-settings`.
|
|
179
|
-
*
|
|
180
|
-
* Events subscribed: session_start, input, agent_start, message_start,
|
|
181
|
-
* message_update, message_end, tool_execution_start, agent_settled,
|
|
182
|
-
* session_shutdown.
|
|
183
|
-
*
|
|
184
|
-
* Callback signatures follow the Pi ExtensionAPI.on() contract:
|
|
185
|
-
* - Pi event payload as first argument (or _event if unused)
|
|
186
|
-
* - ExtensionContext as second argument providing { hasUI, mode, ui }
|
|
187
|
-
*/
|
|
188
|
-
export default function (pi: ExtensionAPI) {
|
|
189
|
-
const liveWordCounter = new StreamingWordCounter();
|
|
190
|
-
let state = makeFreshState();
|
|
191
|
-
let settings = loadSettings();
|
|
192
|
-
// Tracks the most recent ctx handed to us so the interval below never acts
|
|
193
|
-
// on a stale closure captured when the timer was first created.
|
|
194
|
-
let currentCtx: ExtensionContext | null = null;
|
|
195
|
-
|
|
196
|
-
function applyIndicator(ctx: ExtensionContext) {
|
|
197
|
-
ctx.ui.setWorkingIndicator(settings.decorations.animatedSpinner ? undefined : { frames: [] });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function stopTimer() {
|
|
201
|
-
if (state.timer) {
|
|
202
|
-
clearInterval(state.timer);
|
|
203
|
-
state.timer = null;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function resetTurn(now: number) {
|
|
208
|
-
state.startTime = now;
|
|
209
|
-
state.shimmerOrigin = now;
|
|
210
|
-
state.currentWord = pickRandomWord();
|
|
211
|
-
state.confirmTokens = 0;
|
|
212
|
-
state.liveTokens = 0;
|
|
213
|
-
state.activityMeter.reset();
|
|
214
|
-
state.rateTracker.reset();
|
|
215
|
-
state.lastActivityMeterUpdate = 0;
|
|
216
|
-
liveWordCounter.reset();
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function tick() {
|
|
220
|
-
const ctx = currentCtx;
|
|
221
|
-
if (!state.busy || !ctx) return;
|
|
222
|
-
const now = Date.now();
|
|
223
|
-
const elapsedStr = formatElapsed(now - state.startTime);
|
|
224
|
-
const total = state.confirmTokens + state.liveTokens;
|
|
225
|
-
const tokenStr = formatTokens(total);
|
|
226
|
-
updateMeter(now, total);
|
|
227
|
-
|
|
228
|
-
const { substituteDefaultMessage, elapsedTime, outputTokens } = settings.features;
|
|
229
|
-
const { shimmer, tokenActivityMonitor } = settings.decorations;
|
|
230
|
-
|
|
231
|
-
if (isFullyDefaultAppearance(substituteDefaultMessage, shimmer, tokenActivityMonitor, elapsedTime, outputTokens)) {
|
|
232
|
-
ctx.ui.setWorkingMessage();
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const word = substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
|
|
237
|
-
const shimmered = shimmer
|
|
238
|
-
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme)
|
|
239
|
-
: ctx.ui.theme.fg("text", word);
|
|
240
|
-
const frame = tokenActivityMonitor ? renderFrame(ctx) : "";
|
|
241
|
-
const msg = buildMessage(ctx, shimmered, frame, elapsedStr, tokenStr, settings.features);
|
|
242
|
-
ctx.ui.setWorkingMessage(msg);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function updateMeter(now: number, total: number): void {
|
|
246
|
-
if (now - state.lastActivityMeterUpdate >= ACTIVITY_METER_INTERVAL_MS) {
|
|
247
|
-
const rate = state.rateTracker.sample(total, now);
|
|
248
|
-
state.activityMeter.push(rateToLevel(rate));
|
|
249
|
-
state.lastActivityMeterUpdate = now;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function renderFrame(ctx: ExtensionContext): string {
|
|
254
|
-
return state.activityMeter.render((level, char) => ActivityMeter.colorizeCell(level, char, ctx.ui.theme));
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function buildMessage(
|
|
258
|
-
ctx: ExtensionContext,
|
|
259
|
-
shimmered: string,
|
|
260
|
-
frame: string,
|
|
261
|
-
elapsedStr: string,
|
|
262
|
-
tokenStr: string,
|
|
263
|
-
features: { elapsedTime: boolean; outputTokens: boolean },
|
|
264
|
-
): string {
|
|
265
|
-
const parts: string[] = [shimmered];
|
|
266
|
-
if (frame) parts.push(frame);
|
|
267
|
-
|
|
268
|
-
const details: string[] = [];
|
|
269
|
-
if (features.elapsedTime) details.push(elapsedStr);
|
|
270
|
-
if (features.outputTokens) details.push(`\u2193 ${tokenStr} tokens`);
|
|
271
|
-
if (details.length > 0) {
|
|
272
|
-
parts.push(ctx.ui.theme.fg("dim", `(${details.join(" \u00b7 ")})`));
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return parts.filter((part) => part.length > 0).join(" ");
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Builds a self-contained preview renderer for the settings menu: a fake
|
|
280
|
-
* spinner, a fake word with continuous shimmer, a fake breathing (idle to
|
|
281
|
-
* full and back) activity meter, and a real live-incrementing elapsed timer, all gated by
|
|
282
|
-
* the same toggles as the real working message so the preview is an exact
|
|
283
|
-
* simulation. State (word, meter, rate tracker) is scoped per menu open so
|
|
284
|
-
* each invocation of `/topping-settings` starts the animation
|
|
285
|
-
* fresh.
|
|
286
|
-
*/
|
|
287
|
-
function makePreviewRenderer(ctx: ExtensionContext): (values: Record<string, boolean>, elapsedMs: number) => string[] {
|
|
288
|
-
const word = pickRandomWord();
|
|
289
|
-
const meter = new ActivityMeter();
|
|
290
|
-
let lastMeterUpdate = 0;
|
|
291
|
-
|
|
292
|
-
return (values, elapsedMs) => {
|
|
293
|
-
const simulatedTokens = Math.max(0, Math.floor((elapsedMs / 1000) * PREVIEW_TOKEN_RATE_PER_SEC));
|
|
294
|
-
if (elapsedMs - lastMeterUpdate >= ACTIVITY_METER_INTERVAL_MS) {
|
|
295
|
-
meter.push(rateToLevel(previewMeterRate(elapsedMs)));
|
|
296
|
-
lastMeterUpdate = elapsedMs;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const spinnerFrame =
|
|
300
|
-
PREVIEW_SPINNER_FRAMES[Math.floor(elapsedMs / PREVIEW_SPINNER_INTERVAL_MS) % PREVIEW_SPINNER_FRAMES.length]!;
|
|
301
|
-
const spinnerGlyph = values.animatedSpinner ? ctx.ui.theme.fg("accent", spinnerFrame) : "";
|
|
302
|
-
|
|
303
|
-
const substituteDefaultMessage = values.substituteDefaultMessage ?? true;
|
|
304
|
-
const shimmer = values.shimmer ?? true;
|
|
305
|
-
const tokenActivityMonitor = values.tokenActivityMonitor ?? true;
|
|
306
|
-
const elapsedTime = values.elapsedTime ?? true;
|
|
307
|
-
const outputTokens = values.outputTokens ?? true;
|
|
308
|
-
|
|
309
|
-
if (isFullyDefaultAppearance(substituteDefaultMessage, shimmer, tokenActivityMonitor, elapsedTime, outputTokens)) {
|
|
310
|
-
return [[spinnerGlyph, ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD)].filter((part) => part.length > 0).join(" ")];
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const displayWord = substituteDefaultMessage ? word : DEFAULT_WORKING_WORD;
|
|
314
|
-
const elapsedStr = formatElapsed(elapsedMs);
|
|
315
|
-
const tokenStr = formatTokens(simulatedTokens);
|
|
316
|
-
const shimmered = shimmer
|
|
317
|
-
? shimmerString(displayWord, elapsedMs, ctx.ui.theme)
|
|
318
|
-
: ctx.ui.theme.fg("text", displayWord);
|
|
319
|
-
const frame = tokenActivityMonitor
|
|
320
|
-
? meter.render((level, char) => ActivityMeter.colorizeCell(level, char, ctx.ui.theme))
|
|
321
|
-
: "";
|
|
322
|
-
const message = buildMessage(ctx, shimmered, frame, elapsedStr, tokenStr, { elapsedTime, outputTokens });
|
|
323
|
-
|
|
324
|
-
return [[spinnerGlyph, message].filter((part) => part.length > 0).join(" ")];
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function startTimer() {
|
|
329
|
-
if (state.timer) return;
|
|
330
|
-
state.timer = setInterval(tick, SHIMMER_INTERVAL);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const useUI = (ctx: ExtensionContext): boolean => {
|
|
334
|
-
if (!ctx.hasUI || ctx.mode === "print") return false;
|
|
335
|
-
currentCtx = ctx;
|
|
336
|
-
return true;
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
340
|
-
stopTimer();
|
|
341
|
-
liveWordCounter.reset();
|
|
342
|
-
currentCtx = null;
|
|
343
|
-
state = makeFreshState();
|
|
344
|
-
settings = loadSettings();
|
|
345
|
-
if (useUI(ctx)) applyIndicator(ctx);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
pi.on("input", async (_event, ctx) => {
|
|
349
|
-
if (!useUI(ctx)) return;
|
|
350
|
-
resetTurn(Date.now());
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
pi.on("agent_start", async (_event, ctx) => {
|
|
354
|
-
if (!useUI(ctx)) return;
|
|
355
|
-
if (state.startTime === 0) resetTurn(Date.now());
|
|
356
|
-
state.busy = true;
|
|
357
|
-
startTimer();
|
|
358
|
-
tick();
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
pi.on("message_start", async (event, ctx) => {
|
|
362
|
-
if (!useUI(ctx)) return;
|
|
363
|
-
if (event.message.role === "assistant") {
|
|
364
|
-
state.liveTokens = 0;
|
|
365
|
-
liveWordCounter.reset();
|
|
366
|
-
}
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
pi.on("message_update", async (event, ctx) => {
|
|
370
|
-
if (!useUI(ctx)) return;
|
|
371
|
-
const ame = event.assistantMessageEvent;
|
|
372
|
-
if (ame && (ame.type === "text_delta" || ame.type === "thinking_delta")) {
|
|
373
|
-
state.liveTokens += liveWordCounter.count(ame.delta, ame.type);
|
|
374
|
-
}
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
pi.on("message_end", async (event, ctx) => {
|
|
378
|
-
if (!useUI(ctx)) return;
|
|
379
|
-
if (event.message.role !== "assistant") return;
|
|
380
|
-
state.confirmTokens += event.message.usage?.output ?? state.liveTokens;
|
|
381
|
-
state.liveTokens = 0;
|
|
382
|
-
liveWordCounter.reset();
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
386
|
-
if (!useUI(ctx)) return;
|
|
387
|
-
state.currentWord = pickRandomWord();
|
|
388
|
-
state.shimmerOrigin = Date.now();
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
pi.on("agent_settled", async (_event, ctx) => {
|
|
392
|
-
if (!useUI(ctx)) return;
|
|
393
|
-
state.busy = false;
|
|
394
|
-
state.startTime = 0;
|
|
395
|
-
liveWordCounter.reset();
|
|
396
|
-
stopTimer();
|
|
397
|
-
ctx.ui.setWorkingMessage();
|
|
398
|
-
applyIndicator(ctx);
|
|
399
|
-
state.activityMeter.reset();
|
|
400
|
-
state.rateTracker.reset();
|
|
401
|
-
state.lastActivityMeterUpdate = 0;
|
|
402
|
-
currentCtx = null;
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
406
|
-
stopTimer();
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
pi.registerCommand("topping-settings", {
|
|
410
|
-
description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
|
|
411
|
-
handler: async (_args, ctx) => {
|
|
412
|
-
if (ctx.mode !== "tui") {
|
|
413
|
-
ctx.ui.notify("/topping-settings requires TUI mode", "error");
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const before = settings.decorations.animatedSpinner;
|
|
418
|
-
const sections: MenuSection[] = [
|
|
419
|
-
{
|
|
420
|
-
title: "Decorations",
|
|
421
|
-
items: [
|
|
422
|
-
{ id: "animatedSpinner", label: "Animated spinner", value: settings.decorations.animatedSpinner },
|
|
423
|
-
{ id: "shimmer", label: "\u201cWorking...\u201d text shimmer", value: settings.decorations.shimmer },
|
|
424
|
-
{
|
|
425
|
-
id: "tokenActivityMonitor",
|
|
426
|
-
label: "Token activity monitor",
|
|
427
|
-
value: settings.decorations.tokenActivityMonitor,
|
|
428
|
-
},
|
|
429
|
-
],
|
|
430
|
-
},
|
|
431
|
-
{
|
|
432
|
-
title: "Features",
|
|
433
|
-
items: [
|
|
434
|
-
{
|
|
435
|
-
id: "substituteDefaultMessage",
|
|
436
|
-
label: "Substitute Pi's \u201cWorking...\u201d message",
|
|
437
|
-
value: settings.features.substituteDefaultMessage,
|
|
438
|
-
},
|
|
439
|
-
{ id: "elapsedTime", label: "Elapsed time since prompt", value: settings.features.elapsedTime },
|
|
440
|
-
{ id: "outputTokens", label: "Show output tokens", value: settings.features.outputTokens },
|
|
441
|
-
],
|
|
442
|
-
},
|
|
443
|
-
];
|
|
444
|
-
|
|
445
|
-
const result = await showMenu<Record<string, boolean>>(ctx, {
|
|
446
|
-
title: "Pi Topping: Settings",
|
|
447
|
-
sections,
|
|
448
|
-
hints: ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"],
|
|
449
|
-
preview: makePreviewRenderer(ctx),
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
if (!result.applied) return;
|
|
453
|
-
|
|
454
|
-
const next = cloneSettings(settings);
|
|
455
|
-
applyMenuValues(next.decorations, result.values);
|
|
456
|
-
applyMenuValues(next.features, result.values);
|
|
457
|
-
|
|
458
|
-
settings = next;
|
|
459
|
-
saveSettings(settings);
|
|
460
|
-
if (settings.decorations.animatedSpinner !== before) applyIndicator(ctx);
|
|
461
|
-
ctx.ui.notify("Pi Topping settings saved", "info");
|
|
462
|
-
},
|
|
463
|
-
});
|
|
5
|
+
export default function (pi: ExtensionAPI): void {
|
|
6
|
+
new SessionManager(pi).install();
|
|
464
7
|
}
|