@underactive/pi-topping 0.11.1 → 0.13.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
@@ -14,16 +14,27 @@ import type {
14
14
  SessionStartEvent,
15
15
  Theme,
16
16
  } from "@earendil-works/pi-coding-agent";
17
+ import { VERSION } from "@earendil-works/pi-coding-agent";
18
+ import { CHANNELS, PI_VIRTUAL_MODEL_API, WORKING_STATUS_SLIDE_MS, subscribe } from "@underactive/pi-topping-primitives/contracts";
17
19
  import { Text, type Component } from "@earendil-works/pi-tui";
18
- import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
19
- import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./routesense-feed.ts";
20
+ import {
21
+ ActivityMeter,
22
+ FADE_SHADE_COUNT,
23
+ formatElapsedUnits,
24
+ RateHoldFade,
25
+ rateToLevel,
26
+ slideOutTail,
27
+ StreamingWordCounter,
28
+ } from "@underactive/pi-topping-primitives/activity";
20
29
  import {
21
30
  dimAttribute,
22
- getThinkingLevelColorizer,
31
+ fadeThemeColorString,
32
+ renderMeter,
33
+ thinkingLevelColorizer,
34
+ } from "@underactive/pi-topping-primitives/activity-tui";
35
+ import {
23
36
  isThinkingLevel,
24
37
  ELAPSED_INTERVAL_MS,
25
- fadeThemeColorString,
26
- formatElapsed,
27
38
  formatTokenRate,
28
39
  formatTokens,
29
40
  isFullyDefaultAppearance,
@@ -31,26 +42,30 @@ import {
31
42
  RESPONSE_MODEL_FADE_MS,
32
43
  RESPONSE_MODEL_HOLD_MS,
33
44
  RESPONSE_MODEL_SLIDE_FRAME_MS,
34
- RESPONSE_MODEL_SLIDE_MS,
35
45
  SHIMMER_INTERVAL_MS,
36
- slideOutTail,
37
46
  SPINNER_FRAME_MS,
38
47
  SPINNER_FRAMES,
39
- TOKEN_RATE_FADE_SHADE_COUNT,
40
- StreamingWordCounter,
41
48
  type ThinkingLevel,
42
49
  } from "./format.ts";
43
50
  import { buildLoaderMessage } from "./loader-message.ts";
44
- import { showMenu } from "./menu.ts";
45
- import { applyOutputStyle, foldedOutputStyleSection, OUTPUT_STYLE_MESSAGE_TYPE, OUTPUT_STYLE_SECTION, OUTPUT_STYLES, outputStyleSectionPatch } from "./output-style.ts";
51
+ import { showMenu } from "@underactive/pi-topping-primitives/menu";
52
+ import { applyOutputStyle, OUTPUT_STYLES } from "./output-style.ts";
46
53
  import { getResponseModelColorizer } from "./nvidia-green.ts";
47
54
  import { registerSetupCommand } from "./setup-command.ts";
48
55
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
49
- import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.ts";
50
56
  import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
51
57
  import { PreviewRenderer } from "./preview.ts";
52
- import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
53
- import { isPlainObject, modelsResemble, sanitizedText, stripControlChars } from "./util.ts";
58
+ import {
59
+ buildCompletionMarkerContent,
60
+ buildCompletionMarkerLine,
61
+ PROMPT_BOX_ENTRY_TYPE,
62
+ PROMPT_BOX_TYPE,
63
+ promptBoxEntryRenderer,
64
+ promptBoxRenderer,
65
+ type PromptBoxDetails,
66
+ type PromptBoxEntryData,
67
+ } from "./prompt-decorator.ts";
68
+ import { hostBelow, isPlainObject, modelsResemble, modelsSpelledAlike, sanitizedText, stripControlChars } from "./util.ts";
54
69
  import { formatServiceTierSummary, recordServiceTierCall, serviceTierCountsOf, serviceTierOf, type ServiceTierCount } from "./service-tier.ts";
55
70
  import { servedModelOf } from "./served-model.ts";
56
71
  import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
@@ -61,14 +76,16 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
61
76
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
62
77
  type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
63
78
  type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
64
- type ContextWithSystemEvent = Extract<ExtensionEvent, { type: "context_with_system" }>;
65
79
  type ProviderStreamEvent = Extract<ExtensionEvent, { type: "provider_stream_event" }>;
66
80
  type UIPromptKind = UIPromptStartEvent["kind"];
67
81
 
82
+ /**
83
+ * Oldest Pi this extension supports. Pi 1.2.0 runs before_agent_start for the run that a custom
84
+ * message starts, which is how the output style reaches a decorated prompt.
85
+ */
86
+ export const MIN_HOST_VERSION = "1.2.0";
68
87
  const DONE_ENTRY_TYPE = "pi-topping-done";
69
88
  const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
70
- /** Pi's `VIRTUAL_MODEL_API` for extension-registered virtual models; Pi does not export it from its public index. */
71
- const VIRTUAL_MODEL_API = "pi-virtual";
72
89
  const WAITING_LABELS: Record<UIPromptKind, string> = {
73
90
  select: "Waiting for selection",
74
91
  confirm: "Waiting for confirmation",
@@ -115,9 +132,7 @@ interface SessionState {
115
132
  liveTokens: number;
116
133
  shimmerOrigin: number;
117
134
  activityMeter: ActivityMeter;
118
- rateTracker: TokRateTracker;
119
- lastTokenRateSampledAt: number;
120
- lastTokenRateTotal: number;
135
+ tokenRate: RateHoldFade;
121
136
  responseModel: string;
122
137
  served: { model: string; backend: string; routedId: string } | null;
123
138
  /** The backend companion to the last raw response-model report. */
@@ -133,8 +148,6 @@ interface SessionState {
133
148
  /** When the response model appeared in the loader and began sliding out; 0 if it never slid. */
134
149
  responseModelShownAt: number;
135
150
  responseModelSlideTimer: ReturnType<typeof setInterval> | null;
136
- tokenRateText: string;
137
- tokenRateFadeStartsAt: number;
138
151
  timer: ReturnType<typeof setInterval> | null;
139
152
  busy: boolean;
140
153
  waiting: { kind: UIPromptKind; title?: string } | null;
@@ -146,15 +159,6 @@ interface SessionState {
146
159
  /** Sentinel distinguishing "never called setWorkingMessage()" from an explicit `undefined` message. */
147
160
  const NOT_SENT = Symbol("not-sent");
148
161
 
149
- /** Reset all token-rate tracking fields on `state` to their fresh-turn values. */
150
- function resetTokenRateState(state: SessionState): void {
151
- state.rateTracker.reset();
152
- state.lastTokenRateSampledAt = 0;
153
- state.lastTokenRateTotal = 0;
154
- state.tokenRateText = "";
155
- state.tokenRateFadeStartsAt = 0;
156
- }
157
-
158
162
  function makeFreshState(): SessionState {
159
163
  return {
160
164
  startTime: 0,
@@ -163,9 +167,12 @@ function makeFreshState(): SessionState {
163
167
  liveTokens: 0,
164
168
  shimmerOrigin: 0,
165
169
  activityMeter: new ActivityMeter(),
166
- rateTracker: new TokRateTracker(),
167
- lastTokenRateSampledAt: 0,
168
- lastTokenRateTotal: 0,
170
+ tokenRate: new RateHoldFade({
171
+ holdMs: TOKEN_RATE_HOLD_MS,
172
+ fadeMs: TOKEN_RATE_FADE_MS,
173
+ shadeCount: FADE_SHADE_COUNT,
174
+ sampleIntervalMs: METER_INTERVAL_MS,
175
+ }),
169
176
  responseModel: "",
170
177
  served: null,
171
178
  lastResponseModelBackend: NOT_SENT,
@@ -177,8 +184,6 @@ function makeFreshState(): SessionState {
177
184
  responseModelFadeGeneration: 0,
178
185
  responseModelShownAt: 0,
179
186
  responseModelSlideTimer: null,
180
- tokenRateText: "",
181
- tokenRateFadeStartsAt: 0,
182
187
  timer: null,
183
188
  busy: false,
184
189
  waiting: null,
@@ -203,10 +208,14 @@ export class SessionManager {
203
208
  #currentCtx: ExtensionContext | null = null;
204
209
  /** Whether pi-topping-statusline last announced that its status bar hosts the loader; kept across sessions. */
205
210
  #loaderInStatusline = false;
211
+ /** Whether this instance showed the old-host warning. */
212
+ #warnedOldHost = false;
206
213
  readonly #pi: ExtensionAPI;
214
+ readonly #hostVersion: string;
207
215
 
208
- constructor(pi: ExtensionAPI) {
216
+ constructor(pi: ExtensionAPI, hostVersion: string = VERSION) {
209
217
  this.#pi = pi;
218
+ this.#hostVersion = hostVersion;
210
219
  }
211
220
 
212
221
  #onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
@@ -223,37 +232,20 @@ export class SessionManager {
223
232
  if (this.usable(ctx)) {
224
233
  this.applyIndicator(ctx);
225
234
  announceMissingToppingsOnce(this.#pi);
235
+ this.warnOldHostOnce(ctx);
226
236
  }
227
237
  };
228
238
 
229
- /** Unlike the UI handlers, this runs in every mode: the output style shapes the model's prompt. */
230
- #onBeforeAgentStart = (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined =>
231
- applyOutputStyle(event.systemPromptOptions?.sections, this.#settings.outputStyle);
232
-
233
239
  /**
234
- * A decorated prompt is a custom message, so Pi does not run before_agent_start for it.
235
- * Put the missing prompt section on the request. A matching transcript section is left alone.
240
+ * Unlike the UI handlers, this runs in every mode: the output style shapes the model's prompt.
241
+ * The prompt section applies to every run. The hidden reminder belongs to a user prompt: one Pi
242
+ * submitted (`messageType: "user"`), or one that pi-topping re-sent as its prompt box, which
243
+ * starts the run as a custom message. A run that another extension's custom message starts gets
244
+ * the section only.
236
245
  */
237
- #onContextWithSystem = (event: ContextWithSystemEvent): { messages: ContextWithSystemEvent["messages"] } | undefined => {
238
- const patch = outputStyleSectionPatch(foldedOutputStyleSection(event.messages), this.#settings.outputStyle);
239
- if (patch === undefined) return undefined;
240
- const messages = event.messages.slice();
241
- let index = messages.length;
242
- for (let i = messages.length - 1; i >= 0; i--) {
243
- const message = messages[i];
244
- if (!message) continue;
245
- if (message.role === "user" || (message.role === "custom" && message.customType === PROMPT_BOX_TYPE)) {
246
- index = i;
247
- break;
248
- }
249
- }
250
- messages.splice(index, 0, {
251
- role: "system",
252
- content: "",
253
- sections: { [OUTPUT_STYLE_SECTION]: patch },
254
- timestamp: Date.now(),
255
- });
256
- return { messages };
246
+ #onBeforeAgentStart = (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined => {
247
+ const result = applyOutputStyle(event.systemPromptOptions?.sections, this.#settings.outputStyle);
248
+ return event.messageType !== "custom" || event.message.customType === PROMPT_BOX_TYPE ? result : undefined;
257
249
  };
258
250
 
259
251
  #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
@@ -262,40 +254,24 @@ export class SessionManager {
262
254
 
263
255
  if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
264
256
  else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
257
+ if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorateSkill(event)) {
258
+ this.#pi.appendEntry<PromptBoxEntryData>(PROMPT_BOX_ENTRY_TYPE, {
259
+ content: event.text,
260
+ details: this.promptBoxDetails(ctx),
261
+ });
262
+ }
265
263
  if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
266
264
  try {
267
- const stylePrompt = OUTPUT_STYLES[this.#settings.outputStyle].prompt;
268
- await this.#pi.sendMessage<PromptBoxDetails>(
265
+ // The prompt box starts the run; before_agent_start adds the output style's section and reminder to it.
266
+ this.#pi.sendMessage<PromptBoxDetails>(
269
267
  {
270
268
  customType: PROMPT_BOX_TYPE,
271
269
  content: event.text,
272
270
  display: true,
273
- details: {
274
- submittedAt: Date.now(),
275
- showIcon: this.#settings.decorations.promptIcon,
276
- showTimestamp: this.#settings.decorations.promptTimestamp,
277
- showProvider: this.#settings.decorations.promptProvider,
278
- showModel: this.#settings.decorations.promptModel,
279
- icon: this.#settings.decorations.useNerdFont ? "" : "π",
280
- provider: ctx.model?.provider,
281
- model: ctx.model?.id,
282
- borderColor: this.#settings.decorations.borderColor,
283
- borderStyle: this.#settings.decorations.borderStyle,
284
- thinkingLevel: ctx.thinkingLevel,
285
- },
271
+ details: this.promptBoxDetails(ctx),
286
272
  },
287
- { triggerTurn: stylePrompt === undefined },
273
+ { triggerTurn: true },
288
274
  );
289
- if (stylePrompt) {
290
- await this.#pi.sendMessage(
291
- {
292
- customType: OUTPUT_STYLE_MESSAGE_TYPE,
293
- content: stylePrompt.reminder,
294
- display: false,
295
- },
296
- { triggerTurn: true },
297
- );
298
- }
299
275
  return { action: "handled" };
300
276
  } catch {
301
277
  ctx.ui.notify("Failed to submit prompt", "error");
@@ -349,11 +325,9 @@ export class SessionManager {
349
325
  typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens >= 0 ? rawTokens : undefined;
350
326
  this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
351
327
  if (exactTokens !== undefined) {
352
- // Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
328
+ // Only restart the sampling here, not the held rate or its fade:
353
329
  // those must keep holding/fading across message boundaries to avoid flicker.
354
- this.#state.rateTracker.reset();
355
- this.#state.lastTokenRateSampledAt = 0;
356
- this.#state.lastTokenRateTotal = this.#state.confirmTokens;
330
+ this.#state.tokenRate.resetSampling(this.#state.confirmTokens);
357
331
  }
358
332
  this.#state.liveTokens = 0;
359
333
  this.#counter.reset();
@@ -422,7 +396,7 @@ export class SessionManager {
422
396
  ctx.ui.setWorkingMessage();
423
397
  if (responseModel) this.startResponseModelFade(ctx, responseModel, responseModelColor, responseModelDimmed);
424
398
  this.#state.activityMeter.reset();
425
- resetTokenRateState(this.#state);
399
+ this.#state.tokenRate.reset();
426
400
  this.#state.lastMessage = NOT_SENT;
427
401
  if (this.#settings.features.doneMarker && hadPrompt) {
428
402
  const aborted = e.aborted === true;
@@ -466,10 +440,10 @@ export class SessionManager {
466
440
  const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
467
441
  const rawModel = sanitizedText(entry.data.model);
468
442
  const modelColored = features.doneMarkerModel && rawModel
469
- ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
443
+ ? thinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
470
444
  : "";
471
445
  const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
472
- const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model, aborted ? "warning" : "dim");
446
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsedUnits(elapsedMs), details, model, aborted ? "warning" : "dim");
473
447
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
474
448
  const borderColor = aborted ? "warning" : this.#settings.decorations.doneMarkerBorderColor;
475
449
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
@@ -495,7 +469,6 @@ export class SessionManager {
495
469
  install(): void {
496
470
  this.#pi.on("session_start", this.#onSessionStart);
497
471
  this.#pi.on("before_agent_start", this.#onBeforeAgentStart);
498
- this.#pi.on("context_with_system", this.#onContextWithSystem);
499
472
  this.#pi.on("input", this.#onInput);
500
473
  this.#pi.on("agent_start", this.#onAgentStart);
501
474
  this.#pi.on("message_start", this.#onMessageStart);
@@ -507,18 +480,18 @@ export class SessionManager {
507
480
  this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
508
481
  this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
509
482
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
510
- this.#pi.events.on(STATUSLINE_EMBED_CHANNEL, (data) => {
511
- this.#loaderInStatusline = announcesEmbedded(data);
483
+ subscribe(this.#pi.events, CHANNELS.workingStatusEmbedded, ({ embedded }) => {
484
+ this.#loaderInStatusline = embedded;
512
485
  });
513
- this.#pi.events.on(ROUTED_MODEL_CHANNEL, (data) => {
486
+ subscribe(this.#pi.events, CHANNELS.routedModel, (announcement) => {
514
487
  // Direct routes serve compaction and other extensions' side requests; a route while idle
515
488
  // is such a background request too, and must not leave a stale model for the next turn.
516
- const announcement = routedModelAnnouncement(data);
517
- if (!announcement || announcement.reason === "direct" || !this.#state.busy) return;
489
+ if (announcement.reason === "direct" || !this.#state.busy) return;
518
490
  this.updateResponseModel({ raw: announcement.modelId, weak: true, provider: announcement.provider, routedId: announcement.modelId });
519
491
  });
520
492
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
521
493
  this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
494
+ this.#pi.registerEntryRenderer<PromptBoxEntryData>(PROMPT_BOX_ENTRY_TYPE, promptBoxEntryRenderer);
522
495
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
523
496
  this.#pi.registerCommand("topping-settings", {
524
497
  description: "Configure the prompt box, working loader, completion marker, word packs, and output style.",
@@ -531,11 +504,49 @@ export class SessionManager {
531
504
  return !!ctx.hasUI && ctx.mode !== "print";
532
505
  }
533
506
 
507
+ private warnOldHostOnce(ctx: ExtensionContext): void {
508
+ if (this.#warnedOldHost || !hostBelow(this.#hostVersion, MIN_HOST_VERSION)) return;
509
+ this.#warnedOldHost = true;
510
+ ctx.ui.notify(
511
+ `Pi Topping needs Pi ${MIN_HOST_VERSION} or later; this is Pi ${this.#hostVersion}. Output styles will not apply to decorated prompts.`,
512
+ "warning",
513
+ );
514
+ }
515
+
534
516
  /**
535
517
  * Mid-stream inputs (steer/follow-up) must pass through untouched: re-sending them via
536
518
  * sendMessage(deliverAs) bypasses Pi's queue state, so the "Steering:"/"Follow-up:" rows
537
519
  * below the editor never render (issue #2).
538
520
  */
521
+ private promptBoxDetails(ctx: ExtensionContext): PromptBoxDetails {
522
+ return {
523
+ submittedAt: Date.now(),
524
+ showIcon: this.#settings.decorations.promptIcon,
525
+ showTimestamp: this.#settings.decorations.promptTimestamp,
526
+ showProvider: this.#settings.decorations.promptProvider,
527
+ showModel: this.#settings.decorations.promptModel,
528
+ icon: this.#settings.decorations.useNerdFont ? "" : "π",
529
+ provider: ctx.model?.provider,
530
+ model: ctx.model?.id,
531
+ borderColor: this.#settings.decorations.borderColor,
532
+ borderStyle: this.#settings.decorations.borderStyle,
533
+ thinkingLevel: ctx.thinkingLevel,
534
+ };
535
+ }
536
+
537
+ private shouldDecorateSkill(event: InputEvent): boolean {
538
+ if (
539
+ event.streamingBehavior ||
540
+ event.source === "extension" ||
541
+ !event.text.trim() ||
542
+ event.images?.length ||
543
+ !event.text.startsWith("/")
544
+ ) return false;
545
+
546
+ const commandName = event.text.slice(1).split(" ", 1)[0]!;
547
+ return this.#pi.getCommands().some((command) => command.source === "skill" && command.name === commandName);
548
+ }
549
+
539
550
  private shouldDecorate(event: InputEvent): boolean {
540
551
  return (
541
552
  !event.streamingBehavior &&
@@ -574,7 +585,7 @@ export class SessionManager {
574
585
 
575
586
  const color = this.#settings.decorations.spinnerColor;
576
587
  ctx.ui.setWorkingIndicator({
577
- frames: SPINNER_FRAMES.map((frame) => getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel)(frame)),
588
+ frames: SPINNER_FRAMES.map((frame) => thinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel)(frame)),
578
589
  });
579
590
  }
580
591
 
@@ -595,12 +606,13 @@ export class SessionManager {
595
606
  if (message.responseModel !== undefined) {
596
607
  return { raw: message.responseModel, weak: false, routedId: sanitizedText(message.model) || undefined };
597
608
  }
598
- if (this.#currentCtx?.model?.api !== VIRTUAL_MODEL_API) return undefined;
609
+ if (this.#currentCtx?.model?.api !== PI_VIRTUAL_MODEL_API) return undefined;
599
610
  return { raw: message.model, weak: true, provider: typeof message.provider === "string" ? message.provider : undefined };
600
611
  }
601
612
 
602
613
  /**
603
- * Whether a response model merely restates the selected model. Under a virtual selection
614
+ * Whether a response model merely restates the selected model: a decorated form of its id,
615
+ * or its id or display name written with other case and separators. Under a virtual selection
604
616
  * the routed model is always another model, so only a failed route, which leaves the
605
617
  * virtual model itself on the message, is hidden; a resemblance check would also hide a
606
618
  * routed model whose id contains the virtual id, such as openrouter's `auto`.
@@ -608,8 +620,8 @@ export class SessionManager {
608
620
  private restatesSelection(report: ResponseModelReport, responseModel: string): boolean {
609
621
  const selected = this.#currentCtx?.model;
610
622
  if (!selected) return false;
611
- if (selected.api === VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
612
- return modelsResemble(selected.id, responseModel);
623
+ if (selected.api === PI_VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
624
+ return modelsResemble(selected.id, responseModel) || modelsSpelledAlike(selected, responseModel);
613
625
  }
614
626
 
615
627
  private updateResponseModel(report: ResponseModelReport | undefined): void {
@@ -660,7 +672,7 @@ export class SessionManager {
660
672
  state.responseModelShownAt = Date.now();
661
673
  state.responseModelSlideTimer = setInterval(() => {
662
674
  this.tick();
663
- if (Date.now() - state.responseModelShownAt >= RESPONSE_MODEL_SLIDE_MS) this.stopResponseModelSlide();
675
+ if (Date.now() - state.responseModelShownAt >= WORKING_STATUS_SLIDE_MS) this.stopResponseModelSlide();
664
676
  }, RESPONSE_MODEL_SLIDE_FRAME_MS);
665
677
  }
666
678
 
@@ -713,12 +725,12 @@ export class SessionManager {
713
725
  render(shade++);
714
726
  this.#state.responseModelFadeTimer = setInterval(() => {
715
727
  if (this.#state.responseModelFadeGeneration !== generation) return;
716
- if (shade >= TOKEN_RATE_FADE_SHADE_COUNT) {
728
+ if (shade >= FADE_SHADE_COUNT) {
717
729
  this.cancelResponseModelFade(ctx);
718
730
  return;
719
731
  }
720
732
  render(shade++);
721
- }, RESPONSE_MODEL_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT);
733
+ }, RESPONSE_MODEL_FADE_MS / FADE_SHADE_COUNT);
722
734
  }, RESPONSE_MODEL_HOLD_MS);
723
735
  }
724
736
 
@@ -755,7 +767,7 @@ export class SessionManager {
755
767
  state.lastResponseModelRaw = NOT_SENT;
756
768
  state.lastResponseModelBackend = NOT_SENT;
757
769
  state.activityMeter.reset();
758
- resetTokenRateState(state);
770
+ state.tokenRate.reset();
759
771
  state.midTurnInputs = 0;
760
772
  state.lastMessage = NOT_SENT;
761
773
  this.#counter.reset();
@@ -778,27 +790,15 @@ export class SessionManager {
778
790
  const total = state.confirmTokens + state.liveTokens;
779
791
  const features = this.#settings.features;
780
792
  const decorations = this.#settings.decorations;
781
- let hasNewTokenCount = false;
782
- if ((decorations.tokenActivityMonitor || features.tokenRate) && now - state.lastTokenRateSampledAt >= METER_INTERVAL_MS) {
783
- // Do not reset the fade for an EMA-only decay while output is quiet.
784
- hasNewTokenCount = total > state.lastTokenRateTotal;
785
- state.lastTokenRateTotal = total;
786
- const tokenRate = state.rateTracker.sample(total, now);
787
- if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate, decorations.meterUpperBoundTps));
788
- state.lastTokenRateSampledAt = now;
793
+ // RateHoldFade samples at most once per METER_INTERVAL_MS and holds a rate only when the
794
+ // total grew, so an EMA-only decay while output is quiet does not reset the fade.
795
+ if ((decorations.tokenActivityMonitor || features.tokenRate) && state.tokenRate.sample(total, now) && decorations.tokenActivityMonitor) {
796
+ state.activityMeter.push(rateToLevel(state.tokenRate.tracker.tokenRate, decorations.meterUpperBoundTps));
789
797
  }
790
798
  const responseModelShown = features.responseModel && state.responseModel
791
799
  ? slideOutTail(state.responseModel, now - state.responseModelShownAt)
792
800
  : "";
793
- if (features.tokenRate) {
794
- const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
795
- if (latestTokenRate && hasNewTokenCount) {
796
- state.tokenRateText = latestTokenRate;
797
- state.tokenRateFadeStartsAt = now + TOKEN_RATE_HOLD_MS;
798
- } else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
799
- state.tokenRateText = "";
800
- }
801
- }
801
+ const tokenRate = state.tokenRate.display(now);
802
802
  const showSpinner = this.spinnerInMessage();
803
803
  const msg = isFullyDefaultAppearance(features, decorations) && !showSpinner && !responseModelShown
804
804
  ? undefined
@@ -813,15 +813,11 @@ export class SessionManager {
813
813
  spinnerAtMs: showSpinner ? now : undefined,
814
814
  word: state.workingText.text,
815
815
  shimmerAtMs: now - state.shimmerOrigin,
816
- renderMeter: (colorizer, dimmed) => state.activityMeter.render((level, char) =>
817
- ActivityMeter.colorizeCell(level, char, ctx.ui.theme, colorizer, dimmed),
818
- ),
816
+ renderMeter: (colorizer, dimmed) => renderMeter(state.activityMeter, ctx.ui.theme, colorizer, dimmed),
819
817
  elapsedMs: now - state.startTime,
820
818
  outputTokens: total,
821
- tokenRateText: state.tokenRateText,
822
- tokenRateShade: now < state.tokenRateFadeStartsAt
823
- ? undefined
824
- : Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
819
+ tokenRateText: features.tokenRate && tokenRate.rate !== null ? formatTokenRate(tokenRate.rate) : "",
820
+ tokenRateShade: tokenRate.phase === "fading" ? tokenRate.fadeShade : undefined,
825
821
  responseModel: responseModelShown,
826
822
  },
827
823
  );
@@ -842,19 +838,17 @@ export class SessionManager {
842
838
  const before = this.indicatorFingerprint();
843
839
  const styleBefore = this.#settings.outputStyle;
844
840
  const preview = new PreviewRenderer(ctx, this.#allPacks);
845
- const result = await showMenu<Record<string, boolean | string>>(ctx, {
841
+ const result = await showMenu(ctx, {
846
842
  title: "Pi Topping: Settings",
847
- maxHeight: "75%",
843
+ layout: { maxHeight: "75%" },
848
844
  sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
849
845
  hints: ["↑↓ move", "PgUp/PgDn page", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
850
- preview: preview.render.bind(preview),
846
+ preview,
851
847
  });
852
848
  if (!result.applied) return;
853
849
 
854
850
  const updatedSettings = applyMenuResult(this.#settings, result.values);
855
- try {
856
- saveSettings(updatedSettings);
857
- } catch {
851
+ if (!saveSettings(updatedSettings).ok) {
858
852
  ctx.ui.notify("Failed to save Pi Topping settings", "error");
859
853
  return;
860
854
  }