@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 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
- ╟─ Features ────────────────────────────────────────╢
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
- **Features** — whether to substitute the random word at all, the elapsed timer, and the token count.
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) return this.#rate;
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 instantRate = Math.max(0, (totalTokens - this.#lastTotal) / elapsedSeconds);
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 scrolling activity meter. */
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.#levels.pop();
78
- this.#levels.unshift(level);
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: 1_000_000, divisor: 1_000, decimals: 0, suffix: "k" },
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 `${Math.round(count / 1_000_000)}M`;
20
+ return `${(count / 1_000_000_000).toFixed(1)}B`;
17
21
  }
18
22
 
19
- /** Format elapsed milliseconds as minutes and zero-padded seconds. */
23
+ /** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
20
24
  export function formatElapsed(ms: number): string {
21
- const totalSeconds = Math.max(0, Math.floor(ms / 1000));
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
- return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
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
- * pi-topping
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
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
- import { formatElapsed, formatTokens, StreamingWordCounter } from "./format.ts";
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
  }