@underactive/pi-topping 0.3.1 → 0.5.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/src/session.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AgentSettledEvent,
3
3
  AgentStartEvent,
4
+ CustomEntry,
4
5
  ExtensionAPI,
5
6
  ExtensionCommandContext,
6
7
  ExtensionContext,
@@ -9,22 +10,33 @@ import type {
9
10
  InputEventResult,
10
11
  SessionShutdownEvent,
11
12
  SessionStartEvent,
13
+ Theme,
12
14
  } from "@earendil-works/pi-coding-agent";
13
- import { Text } from "@earendil-works/pi-tui";
15
+ import { Text, type Component } from "@earendil-works/pi-tui";
14
16
  import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
15
17
  import {
16
18
  buildWorkingMessage,
19
+ DEFAULT_WORKING_WORD,
20
+ dimAttribute,
21
+ ELAPSED_INTERVAL_MS,
22
+ fadeThemeColorString,
17
23
  formatElapsed,
24
+ formatTokenRate,
18
25
  formatTokens,
19
26
  isFullyDefaultAppearance,
27
+ METER_INTERVAL_MS,
28
+ SHIMMER_INTERVAL_MS,
20
29
  shimmerString,
30
+ SPINNER_FRAME_MS,
21
31
  SPINNER_FRAMES,
32
+ TOKEN_RATE_FADE_SHADE_COUNT,
33
+ TOKEN_RATE_PLACEHOLDER,
22
34
  StreamingWordCounter,
23
35
  } from "./format.ts";
24
36
  import { showMenu } from "./menu.ts";
25
- import { applyMenuResult, buildMenuSections, loadSettings, saveSettings } from "./settings.ts";
37
+ import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type SettingColor } from "./settings.ts";
26
38
  import { PreviewRenderer } from "./preview.ts";
27
- import { PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
39
+ import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, stripControlChars, type PromptBoxDetails } from "./prompt-decorator.ts";
28
40
  import { pickRandomWord, pickRawWord } from "./words.ts";
29
41
 
30
42
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
@@ -33,18 +45,18 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
33
45
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
34
46
 
35
47
  const DONE_ENTRY_TYPE = "pi-topping-done";
36
- const DEFAULT_WORKING_WORD = "Working…";
37
- const SHIMMER_INTERVAL = 50;
38
- const METER_INTERVAL_MS = 100;
39
- const ELAPSED_INTERVAL_MS = 1_000;
48
+ // Reconciliation resets the EMA at message boundaries, so retain and then fade the last rate to avoid flicker.
49
+ const TOKEN_RATE_HOLD_MS = 1_500;
50
+ const TOKEN_RATE_FADE_MS = 250;
40
51
 
41
- export interface DoneEntryData {
52
+ interface DoneEntryData {
42
53
  word: string;
43
54
  elapsedMs: number;
44
- tokens: number;
55
+ tokens?: number;
56
+ midTurnInputs?: number;
45
57
  }
46
58
 
47
- export interface SessionState {
59
+ interface SessionState {
48
60
  startTime: number;
49
61
  currentWord: string;
50
62
  confirmTokens: number;
@@ -52,12 +64,30 @@ export interface SessionState {
52
64
  shimmerOrigin: number;
53
65
  activityMeter: ActivityMeter;
54
66
  rateTracker: TokRateTracker;
55
- lastActivityMeterUpdate: number;
67
+ lastTokenRateSampledAt: number;
68
+ lastTokenRateTotal: number;
69
+ tokenRateText: string;
70
+ tokenRateFadeStartsAt: number;
56
71
  timer: ReturnType<typeof setInterval> | null;
57
72
  busy: boolean;
73
+ midTurnInputs: number;
74
+ /** Last string passed to setWorkingMessage(), or the NOT_SENT sentinel before the first call. */
75
+ lastMessage: string | undefined | typeof NOT_SENT;
58
76
  }
59
77
 
60
- export function makeFreshState(): SessionState {
78
+ /** Sentinel distinguishing "never called setWorkingMessage()" from an explicit `undefined` message. */
79
+ const NOT_SENT = Symbol("not-sent");
80
+
81
+ /** Reset all token-rate tracking fields on `state` to their fresh-turn values. */
82
+ function resetTokenRateState(state: SessionState): void {
83
+ state.rateTracker.reset();
84
+ state.lastTokenRateSampledAt = 0;
85
+ state.lastTokenRateTotal = 0;
86
+ state.tokenRateText = "";
87
+ state.tokenRateFadeStartsAt = 0;
88
+ }
89
+
90
+ function makeFreshState(): SessionState {
61
91
  return {
62
92
  startTime: 0,
63
93
  currentWord: "",
@@ -66,9 +96,14 @@ export function makeFreshState(): SessionState {
66
96
  shimmerOrigin: 0,
67
97
  activityMeter: new ActivityMeter(),
68
98
  rateTracker: new TokRateTracker(),
69
- lastActivityMeterUpdate: 0,
99
+ lastTokenRateSampledAt: 0,
100
+ lastTokenRateTotal: 0,
101
+ tokenRateText: "",
102
+ tokenRateFadeStartsAt: 0,
70
103
  timer: null,
71
104
  busy: false,
105
+ midTurnInputs: 0,
106
+ lastMessage: NOT_SENT,
72
107
  };
73
108
  }
74
109
 
@@ -87,7 +122,6 @@ export class SessionManager {
87
122
  #onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
88
123
  this.stopTimer();
89
124
  this.#counter.reset();
90
- this.#currentCtx = null;
91
125
  this.#state = makeFreshState();
92
126
  this.#settings = loadSettings();
93
127
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
@@ -99,7 +133,8 @@ export class SessionManager {
99
133
  this.#currentCtx = ctx;
100
134
  if (!this.usable(ctx)) return;
101
135
 
102
- this.resetTurn(Date.now());
136
+ if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
137
+ else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
103
138
  if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
104
139
  this.#pi.sendMessage<PromptBoxDetails>(
105
140
  {
@@ -110,8 +145,13 @@ export class SessionManager {
110
145
  submittedAt: Date.now(),
111
146
  showIcon: this.#settings.decorations.promptIcon,
112
147
  showTimestamp: this.#settings.decorations.promptTimestamp,
148
+ showProvider: this.#settings.decorations.promptProvider,
149
+ showModel: this.#settings.decorations.promptModel,
113
150
  icon: this.#settings.decorations.useNerdFont ? "" : "π",
151
+ provider: ctx.model?.provider,
152
+ model: ctx.model?.id,
114
153
  borderColor: this.#settings.decorations.borderColor,
154
+ borderStyle: this.#settings.decorations.borderStyle,
115
155
  },
116
156
  },
117
157
  {
@@ -159,8 +199,11 @@ export class SessionManager {
159
199
  const exactTokens = event.message.usage?.output;
160
200
  this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
161
201
  if (exactTokens !== undefined) {
202
+ // Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
203
+ // those must keep holding/fading across message boundaries to avoid flicker.
162
204
  this.#state.rateTracker.reset();
163
- this.#state.lastActivityMeterUpdate = 0;
205
+ this.#state.lastTokenRateSampledAt = 0;
206
+ this.#state.lastTokenRateTotal = this.#state.confirmTokens;
164
207
  }
165
208
  this.#state.liveTokens = 0;
166
209
  this.#counter.reset();
@@ -188,13 +231,14 @@ export class SessionManager {
188
231
  ctx.ui.setWorkingMessage();
189
232
  this.applyIndicator(ctx);
190
233
  this.#state.activityMeter.reset();
191
- this.#state.rateTracker.reset();
192
- this.#state.lastActivityMeterUpdate = 0;
234
+ resetTokenRateState(this.#state);
235
+ this.#state.lastMessage = NOT_SENT;
193
236
  if (this.#settings.features.doneMarker && hadPrompt) {
194
237
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
195
238
  word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
196
239
  elapsedMs,
197
240
  tokens: this.#state.confirmTokens,
241
+ midTurnInputs: this.#state.midTurnInputs,
198
242
  });
199
243
  }
200
244
  this.#currentCtx = null;
@@ -204,6 +248,39 @@ export class SessionManager {
204
248
  this.stopTimer();
205
249
  };
206
250
 
251
+ #renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
252
+ if (!entry.data) return undefined;
253
+ const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
254
+ const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
255
+ const features = this.#settings.features;
256
+ const details: string[] = [];
257
+ if (features.doneMarkerTokens && typeof entry.data.tokens === "number") details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
258
+ if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && entry.data.midTurnInputs) {
259
+ details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
260
+ }
261
+ const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
262
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
263
+ const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
264
+ const borderColor = this.#settings.decorations.doneMarkerBorderColor;
265
+ if (borderStyle === "none") return new Text(markerContent);
266
+
267
+ let cachedWidth: number | undefined;
268
+ let cachedLines: string[] | undefined;
269
+ return {
270
+ render(width: number): string[] {
271
+ if (cachedWidth !== width) {
272
+ cachedWidth = width;
273
+ cachedLines = ["", ` ${buildCompletionMarkerLine(markerContent, width - 2, theme, borderStyle, borderColor)}`, ""];
274
+ }
275
+ return cachedLines!;
276
+ },
277
+ invalidate(): void {
278
+ cachedWidth = undefined;
279
+ cachedLines = undefined;
280
+ },
281
+ } satisfies Component;
282
+ }
283
+
207
284
  install(): void {
208
285
  this.#pi.on("session_start", this.#onSessionStart);
209
286
  this.#pi.on("input", this.#onInput);
@@ -214,16 +291,10 @@ export class SessionManager {
214
291
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
215
292
  this.#pi.on("agent_settled", this.#onAgentSettled);
216
293
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
217
- this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) =>
218
- entry.data
219
- ? new Text(
220
- `${this.#settings.features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${this.#settings.features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${this.#settings.features.doneMarkerTokens && entry.data.tokens !== undefined ? ` (↓ ${formatTokens(entry.data.tokens)} tokens)` : ""}`)}`,
221
- )
222
- : undefined,
223
- );
294
+ this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
224
295
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
225
296
  this.#pi.registerCommand("topping-settings", {
226
- description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
297
+ description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
227
298
  handler: async (_a, ctx) => this.showSettings(ctx),
228
299
  });
229
300
  }
@@ -236,14 +307,31 @@ export class SessionManager {
236
307
  return event.source !== "extension" && !!event.text.trim() && !event.images?.length && !/^[\/!?:]/.test(event.text);
237
308
  }
238
309
 
239
- private applyIndicator(ctx: ExtensionContext): void {
310
+ /**
311
+ * Pi's Loader always prepends its indicator to the working message, so a spinner
312
+ * that is not the leading element has to be drawn inside the message instead.
313
+ */
314
+ private spinnerInMessage(): boolean {
315
+ return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
316
+ }
317
+
318
+ private spinnerColor(): SettingColor {
240
319
  const decorations = this.#settings.decorations;
241
- if (!decorations.animatedSpinner) {
320
+ return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
321
+ }
322
+
323
+ private indicatorFingerprint(): string {
324
+ const decorations = this.#settings.decorations;
325
+ return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
326
+ }
327
+
328
+ private applyIndicator(ctx: ExtensionContext): void {
329
+ if (!this.#settings.decorations.animatedSpinner || this.spinnerInMessage()) {
242
330
  ctx.ui.setWorkingIndicator({ frames: [] });
243
331
  return;
244
332
  }
245
333
 
246
- const color = decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
334
+ const color = this.spinnerColor();
247
335
  if (color === "accent") {
248
336
  ctx.ui.setWorkingIndicator(undefined);
249
337
  return;
@@ -263,13 +351,11 @@ export class SessionManager {
263
351
 
264
352
  const features = this.#settings.features;
265
353
  const decorations = this.#settings.decorations;
266
- const interval = decorations.shimmer
267
- ? SHIMMER_INTERVAL
268
- : (decorations.tokenActivityMonitor || features.outputTokens)
269
- ? METER_INTERVAL_MS
270
- : features.elapsedTime
271
- ? ELAPSED_INTERVAL_MS
272
- : undefined;
354
+ let interval: number | undefined;
355
+ if (decorations.shimmer) interval = SHIMMER_INTERVAL_MS;
356
+ else if (decorations.tokenActivityMonitor || features.outputTokens || features.tokenRate) interval = METER_INTERVAL_MS;
357
+ else if (features.elapsedTime) interval = ELAPSED_INTERVAL_MS;
358
+ if (this.spinnerInMessage()) interval = Math.min(interval ?? SPINNER_FRAME_MS, SPINNER_FRAME_MS);
273
359
  if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
274
360
  }
275
361
 
@@ -281,8 +367,9 @@ export class SessionManager {
281
367
  state.confirmTokens = 0;
282
368
  state.liveTokens = 0;
283
369
  state.activityMeter.reset();
284
- state.rateTracker.reset();
285
- state.lastActivityMeterUpdate = 0;
370
+ resetTokenRateState(state);
371
+ state.midTurnInputs = 0;
372
+ state.lastMessage = NOT_SENT;
286
373
  this.#counter.reset();
287
374
  }
288
375
 
@@ -295,18 +382,32 @@ export class SessionManager {
295
382
  const total = state.confirmTokens + state.liveTokens;
296
383
  const features = this.#settings.features;
297
384
  const decorations = this.#settings.decorations;
298
- if (decorations.tokenActivityMonitor && now - state.lastActivityMeterUpdate >= METER_INTERVAL_MS) {
299
- state.activityMeter.push(rateToLevel(state.rateTracker.sample(total, now)));
300
- state.lastActivityMeterUpdate = now;
385
+ let hasNewTokenCount = false;
386
+ if ((decorations.tokenActivityMonitor || features.tokenRate) && now - state.lastTokenRateSampledAt >= METER_INTERVAL_MS) {
387
+ // Do not reset the fade for an EMA-only decay while output is quiet.
388
+ hasNewTokenCount = total > state.lastTokenRateTotal;
389
+ state.lastTokenRateTotal = total;
390
+ const tokenRate = state.rateTracker.sample(total, now);
391
+ if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
392
+ state.lastTokenRateSampledAt = now;
301
393
  }
394
+ const spinner = this.spinnerInMessage()
395
+ ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
396
+ : "";
302
397
  if (isFullyDefaultAppearance(features, decorations)) {
303
- ctx.ui.setWorkingMessage();
398
+ const msg = spinner
399
+ ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
400
+ : undefined;
401
+ if (msg !== state.lastMessage) {
402
+ state.lastMessage = msg;
403
+ ctx.ui.setWorkingMessage(msg);
404
+ }
304
405
  return;
305
406
  }
306
407
 
307
408
  const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
308
409
  const styled = decorations.shimmer
309
- ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection)
410
+ ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
310
411
  : ctx.ui.theme.fg("text", word);
311
412
  const meter = decorations.tokenActivityMonitor
312
413
  ? state.activityMeter.render((level, char) =>
@@ -319,16 +420,46 @@ export class SessionManager {
319
420
  ),
320
421
  )
321
422
  : "";
322
- ctx.ui.setWorkingMessage(
323
- buildWorkingMessage(
324
- ctx.ui.theme,
325
- styled,
423
+ let tokenRateText = "";
424
+ if (features.tokenRate) {
425
+ const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
426
+ if (latestTokenRate && hasNewTokenCount) {
427
+ state.tokenRateText = latestTokenRate;
428
+ state.tokenRateFadeStartsAt = now + TOKEN_RATE_HOLD_MS;
429
+ } else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
430
+ state.tokenRateText = "";
431
+ }
432
+ tokenRateText = state.tokenRateText;
433
+ }
434
+ const tokenRateSegment = !features.tokenRate
435
+ ? ""
436
+ : !tokenRateText
437
+ ? ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER)
438
+ : now < state.tokenRateFadeStartsAt
439
+ ? ctx.ui.theme.fg(decorations.tokenRateColor, tokenRateText)
440
+ : fadeThemeColorString(
441
+ tokenRateText,
442
+ Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
443
+ ctx.ui.theme,
444
+ decorations.tokenRateColor,
445
+ );
446
+ const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
447
+ const msg = buildWorkingMessage(
448
+ ctx.ui.theme,
449
+ {
450
+ spinner,
451
+ text: styled,
326
452
  meter,
327
- formatElapsed(now - state.startTime),
328
- formatTokens(total),
329
- features,
330
- ),
453
+ elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
454
+ tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
455
+ tokenRate: tokenRateStyled,
456
+ },
457
+ this.#settings.loaderOrder,
331
458
  );
459
+ if (msg !== state.lastMessage) {
460
+ state.lastMessage = msg;
461
+ ctx.ui.setWorkingMessage(msg);
462
+ }
332
463
  }
333
464
 
334
465
  private async showSettings(ctx: ExtensionCommandContext): Promise<void> {
@@ -337,7 +468,7 @@ export class SessionManager {
337
468
  return;
338
469
  }
339
470
 
340
- const before = `${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`;
471
+ const before = this.indicatorFingerprint();
341
472
  const preview = new PreviewRenderer(ctx);
342
473
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
343
474
  title: "Pi Topping: Settings",
@@ -356,12 +487,7 @@ export class SessionManager {
356
487
  }
357
488
 
358
489
  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
- }
490
+ if (before !== this.indicatorFingerprint()) this.applyIndicator(ctx);
365
491
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
366
492
  if (this.#state.busy) {
367
493
  this.stopTimer();