@underactive/pi-topping 0.9.0 → 0.10.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
@@ -6,11 +6,11 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
6
6
 
7
7
  ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_user_prompt.png)
8
8
 
9
- **“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
9
+ **“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection (requires Pi ≥0.99.0). Under a Pi virtual model such as pi-topping-autorouter's `litellm-auto/auto`, the response model is the routed physical model. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
10
10
 
11
11
  ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, token count, and token rate](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo.gif)
12
12
 
13
- **Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. It can append the response model when it differs from the selected model, with its own color option. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event.
13
+ **Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. It can append the response model when it differs from the selected model, with its own color option. It can also append per-call service-tier counts (e.g. `flex tier ×3`), but only for calls whose provider reports a tier; this requires Pi ≥0.99.0. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event.
14
14
 
15
15
  ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_completion_marker.png)
16
16
 
@@ -86,6 +86,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
86
86
  ║ [■] Randomize “Worked” text ON ║
87
87
  ║ [■] Tokens spent ON ║
88
88
  ║ [■] Mid-turn inputs ON ║
89
+ ║ [■] Service tier ON ║
89
90
  ║ [■] Response model ON ║
90
91
  ║ [■] Response model color ‹ muted › ║
91
92
  ║ [■] Response model dimmed OFF ║
@@ -109,7 +110,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
109
110
  ╟───────────────────────────────────────────────────────────────╢
110
111
  ║ ↑↓ move PgUp/PgDn page ←→ select ║
111
112
  ║ ␣ toggle ⏎ apply esc cancel ║
112
- ╚═══════════════════════════════════════════════════════[ 9/57 ]╝
113
+ ╚═══════════════════════════════════════════════════════[ 9/58 ]╝
113
114
  ```
114
115
 
115
116
  ### Word packs
@@ -147,7 +148,7 @@ restarts the cycle.
147
148
 
148
149
  The token activity monitor reaches full `⣿` at or above its configurable upper bound (80 tps by default), with lower levels scaled proportionally to that bound. In `/topping-settings`, `←`/`→` cycle through presets spanning 10–1000 tps, and `␣` (Space) lets you enter any whole number from 10–1000. Invalid typed entries show an inline error and keep the editor open without changing the current value; invalid hand-edited `meterUpperBoundTps` values in `settings.json` revert to 80 on load.
149
150
 
150
- The response model is captured from assistant responses, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
151
+ The response model is captured from assistant responses and raw Responses-API stream events (Pi ≥0.99.0), displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. When Ramp Router reports a gateway backend in the raw event, the label can include it as `<model> via <backend>`, or `via <backend>` alone when the served model restates the selection. Under a Pi virtual model such as pi-topping-autorouter's `litellm-auto/auto`, it is the routed physical model: taken from the router's `pi-topping-autorouter:routed-model` announcement when present, which arrives before the first token (announcements for `direct` routes such as compaction are ignored), and otherwise from the assistant message's model. There only a failed route, which reports the virtual model itself, is hidden, and a bare routed id never displaces a provider-reported decorated form of itself such as a dated name. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
151
152
 
152
153
  ## Requirements
153
154
 
@@ -163,13 +164,14 @@ npm test
163
164
  npm run typecheck
164
165
  ```
165
166
 
166
- Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior and its configurable upper bound, timer resets, settings persistence and schema migrations, word-pack parsing, loading, and default-text selection, response-model loader and footer behavior, the `menu.ts` component and its typed number entry, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
167
+ Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior and its configurable upper bound, timer resets, settings persistence and schema migrations, word-pack parsing, loading, and default-text selection, response-model loader and footer behavior, served-model and service-tier extraction, the `menu.ts` component and its typed number entry, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
167
168
 
168
169
  ## Limitations
169
170
 
170
171
  - Token counts are a streaming word-count estimate, not an exact tokenizer count — they self-correct when each message finishes, so you'll see a small jump at that point.
171
172
  - Elapsed time normally starts when you submit a prompt; for auto-continuations without that event, it starts slightly later instead.
172
173
  - If another extension also customizes the working message, whichever one runs last wins.
174
+ - Service-tier details and Responses-API served-model/backend labels depend on Pi's `provider_stream_event`; raw events require Pi ≥0.99.0 and are unavailable on older Pi hosts and SDK adapters that do not expose them.
173
175
 
174
176
  ## Author
175
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -30,8 +30,8 @@
30
30
  "@earendil-works/pi-tui": "*"
31
31
  },
32
32
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.99.1",
34
- "@earendil-works/pi-tui": "0.99.1",
33
+ "@earendil-works/pi-coding-agent": "1.0.3",
34
+ "@earendil-works/pi-tui": "1.0.3",
35
35
  "@types/node": "^24.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },
@@ -0,0 +1,49 @@
1
+ import { isPlainObject, sanitizedText } from "./util.ts";
2
+
3
+ /**
4
+ * Event-bus channel on which pi-topping-autorouter announces the physical model it routed
5
+ * a request to, before Pi sends that request (so before `message_start`). Payload:
6
+ *
7
+ * ```json
8
+ * {
9
+ * "version": 1,
10
+ * "provider": "openai-codex",
11
+ * "modelId": "gpt-5.6-sol",
12
+ * "model": "openai-codex/gpt-5.6-sol",
13
+ * "name": "GPT-5.6 Sol",
14
+ * "thinkingLevel": "high",
15
+ * "reason": "user",
16
+ * "cause": "jev_classifier",
17
+ * "tier": "COMPLEX",
18
+ * "ts": 1790000000000
19
+ * }
20
+ * ```
21
+ *
22
+ * `reason` is Pi's route reason: `user`, `continuation`, `retry`, or `direct`. `direct` routes
23
+ * serve compaction summaries and other extensions' side requests, not the chat response.
24
+ * `tier` may be absent. Shared with pi-topping-autorouter.
25
+ */
26
+ export const ROUTED_MODEL_CHANNEL = "pi-topping-autorouter:routed-model";
27
+
28
+ const ROUTE_REASONS = ["user", "continuation", "retry", "direct"] as const;
29
+ export type RouteReason = (typeof ROUTE_REASONS)[number];
30
+
31
+ export interface RoutedModelAnnouncement {
32
+ provider: string;
33
+ modelId: string;
34
+ reason: RouteReason;
35
+ }
36
+
37
+ function isRouteReason(value: string): value is RouteReason {
38
+ return ROUTE_REASONS.some((reason) => reason === value);
39
+ }
40
+
41
+ /** The sanitized provider, model id, and route reason of a ROUTED_MODEL_CHANNEL announcement, or undefined if malformed. */
42
+ export function routedModelAnnouncement(data: unknown): RoutedModelAnnouncement | undefined {
43
+ if (!isPlainObject(data) || data.version !== 1) return undefined;
44
+ const provider = sanitizedText(data.provider);
45
+ const modelId = sanitizedText(data.modelId);
46
+ const reason = sanitizedText(data.reason);
47
+ if (!provider || !modelId || !isRouteReason(reason)) return undefined;
48
+ return { provider, modelId, reason };
49
+ }
@@ -3,7 +3,7 @@ import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, fadeThemeColor
3
3
  import { getResponseModelColorizer } from "./nvidia-green.ts";
4
4
  import type { DecoratorSettings } from "./settings.ts";
5
5
 
6
- type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate" | "responseModel">;
6
+ type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate">;
7
7
  type Decorations = Pick<DecoratorSettings["decorations"], "shimmer" | "shimmerInverted" | "shimmerDirection" | "shimmerSpeed" | "tokenActivityMonitor" | "meterColor" | "meterDimmed" | "tokenRateColor" | "tokenRateDimmed" | "responseModelColor" | "responseModelDimmed" | "spinnerColor">;
8
8
  interface LoaderParts {
9
9
  spinnerAtMs?: number;
package/src/preview.ts CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRat
5
5
  import { buildLoaderMessage } from "./loader-message.ts";
6
6
  import { getResponseModelColorizer } from "./nvidia-green.ts";
7
7
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
8
+ import { formatServiceTierSummary } from "./service-tier.ts";
8
9
  import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleMeterUpperBound, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
9
10
  import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, wordPacksPath, type WordPack } from "./word-packs.ts";
10
11
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
@@ -143,6 +144,7 @@ export class PreviewRenderer {
143
144
  const details: string[] = [];
144
145
  if (values.doneMarkerTokens === true) details.push("↓ 949 tokens");
145
146
  if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
147
+ if (values.doneMarkerServiceTier === true) details.push(formatServiceTierSummary([{ tier: "flex", calls: 3 }]));
146
148
  let model = "";
147
149
  if (values.doneMarkerModel === true) {
148
150
  const color = isThinkingLevelColor(values.doneMarkerModelColor) ? values.doneMarkerModelColor : DEFAULT_SETTINGS.decorations.doneMarkerModelColor;
@@ -96,7 +96,6 @@ export function buildPromptBoxLines(
96
96
  const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
97
97
  const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
98
98
  const iconColor = (text: string) => theme.fg("text", text);
99
- const label = (text: string) => theme.fg("customMessageLabel", text);
100
99
  const muted = (text: string) => theme.fg("dim", text);
101
100
  const bodyText = (text: string) => theme.fg("customMessageText", text);
102
101
  const time = options.showTimestamp === false || !submittedAt
@@ -0,0 +1,19 @@
1
+ import { isPlainObject, sanitizedText } from "./util.ts";
2
+
3
+ export interface ServedModelReport {
4
+ model: string;
5
+ backend?: string;
6
+ }
7
+
8
+ /** Extract the served model and optional gateway backend from a raw Responses stream event. */
9
+ export function servedModelOf(data: unknown): ServedModelReport | undefined {
10
+ try {
11
+ if (!isPlainObject(data) || !isPlainObject(data.response)) return undefined;
12
+ const model = sanitizedText(data.response.model) || undefined;
13
+ if (!model) return undefined;
14
+ const backend = sanitizedText(data.provider);
15
+ return { model, ...(backend ? { backend } : {}) };
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
@@ -0,0 +1,86 @@
1
+ import { isPlainObject, sanitizedText } from "./util.ts";
2
+
3
+ /** Provider calls that reported one service tier during a run. */
4
+ export interface ServiceTierCount {
5
+ tier: string;
6
+ calls: number;
7
+ }
8
+
9
+ /** Sanitize a provider-supplied tier into a short, safe display label. */
10
+ export function sanitizeServiceTier(raw: unknown): string | undefined {
11
+ let tier = sanitizedText(raw);
12
+ if (tier.length > 32) {
13
+ let end = 0;
14
+ let characters = 0;
15
+ for (const character of tier) {
16
+ if (characters === 32) break;
17
+ end += character.length;
18
+ characters++;
19
+ }
20
+ tier = tier.slice(0, end);
21
+ }
22
+ return tier || undefined;
23
+ }
24
+
25
+ /**
26
+ * Return the first sanitized tier reported in a raw provider stream event, without throwing.
27
+ * OpenAI chat-completions uses `service_tier`; Responses uses `response.service_tier`;
28
+ * Anthropic reports `message.usage.service_tier` at `message_start` and
29
+ * `usage.service_tier` at `message_delta`. `serviceTier` covers camel-case adapters.
30
+ */
31
+ export function serviceTierOf(data: unknown): string | undefined {
32
+ try {
33
+ if (!isPlainObject(data)) return undefined;
34
+ const direct = sanitizeServiceTier(data.service_tier) ?? sanitizeServiceTier(data.serviceTier);
35
+ if (direct) return direct;
36
+ if (isPlainObject(data.response)) {
37
+ const responseTier = sanitizeServiceTier(data.response.service_tier);
38
+ if (responseTier) return responseTier;
39
+ }
40
+ if (isPlainObject(data.usage)) {
41
+ const usageTier = sanitizeServiceTier(data.usage.service_tier);
42
+ if (usageTier) return usageTier;
43
+ }
44
+ if (isPlainObject(data.message) && isPlainObject(data.message.usage)) {
45
+ return sanitizeServiceTier(data.message.usage.service_tier);
46
+ }
47
+ return undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** Increment a tier's provider-call count, appending new tiers in first-seen order. */
54
+ export function recordServiceTierCall(counts: ServiceTierCount[], tier: string): void {
55
+ const existing = counts.find((entry) => entry.tier === tier);
56
+ if (existing) existing.calls++;
57
+ else counts.push({ tier, calls: 1 });
58
+ }
59
+
60
+ /** Validate persisted tier counts, dropping malformed entries and merging sanitized duplicates. */
61
+ export function serviceTierCountsOf(raw: unknown): ServiceTierCount[] {
62
+ if (!Array.isArray(raw)) return [];
63
+ const counts: ServiceTierCount[] = [];
64
+ for (const item of raw) {
65
+ if (!isPlainObject(item)) continue;
66
+ const tier = sanitizeServiceTier(item.tier);
67
+ const calls = item.calls;
68
+ if (!tier || typeof calls !== "number" || !Number.isSafeInteger(calls) || calls <= 0) continue;
69
+ const existing = counts.find((entry) => entry.tier === tier);
70
+ if (existing) {
71
+ const sum = existing.calls + calls;
72
+ if (Number.isSafeInteger(sum)) existing.calls = sum;
73
+ } else {
74
+ counts.push({ tier, calls });
75
+ }
76
+ }
77
+ return counts;
78
+ }
79
+
80
+ /** Format the ordered summary of provider calls that reported a tier. */
81
+ export function formatServiceTierSummary(counts: readonly ServiceTierCount[]): string {
82
+ if (counts.length === 0) return "";
83
+ const totalCalls = counts.reduce((total, entry) => total + entry.calls, 0);
84
+ if (totalCalls === 1) return `${counts[0]!.tier} tier`;
85
+ return counts.map(({ tier, calls }) => `${tier} tier ×${calls}`).join(", ");
86
+ }
package/src/session.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { Text, type Component } from "@earendil-works/pi-tui";
16
16
  import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
17
+ import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./autorouter-feed.ts";
17
18
  import {
18
19
  dimAttribute,
19
20
  getThinkingLevelColorizer,
@@ -46,7 +47,9 @@ import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.
46
47
  import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
47
48
  import { PreviewRenderer } from "./preview.ts";
48
49
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
49
- import { isPlainObject, modelsResemble, stripControlChars } from "./util.ts";
50
+ import { isPlainObject, modelsResemble, sanitizedText, stripControlChars } from "./util.ts";
51
+ import { formatServiceTierSummary, recordServiceTierCall, serviceTierCountsOf, serviceTierOf, type ServiceTierCount } from "./service-tier.ts";
52
+ import { servedModelOf } from "./served-model.ts";
50
53
  import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
51
54
 
52
55
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
@@ -55,10 +58,13 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
55
58
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
56
59
  type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
57
60
  type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
61
+ type ProviderStreamEvent = Extract<ExtensionEvent, { type: "provider_stream_event" }>;
58
62
  type UIPromptKind = UIPromptStartEvent["kind"];
59
63
 
60
64
  const DONE_ENTRY_TYPE = "pi-topping-done";
61
65
  const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
66
+ /** Pi's `VIRTUAL_MODEL_API` for extension-registered virtual models; Pi does not export it from its public index. */
67
+ const VIRTUAL_MODEL_API = "pi-virtual";
62
68
  const WAITING_LABELS: Record<UIPromptKind, string> = {
63
69
  select: "Waiting for selection",
64
70
  confirm: "Waiting for confirmation",
@@ -77,10 +83,26 @@ interface DoneEntryData {
77
83
  elapsedMs: number;
78
84
  tokens?: number;
79
85
  midTurnInputs?: number;
86
+ serviceTiers?: ServiceTierCount[];
80
87
  thinkingLevel?: ThinkingLevel;
81
88
  model?: string;
82
89
  }
83
90
 
91
+ /** A response model as one source reports it, for `updateResponseModel`. */
92
+ interface ResponseModelReport {
93
+ raw: unknown;
94
+ /**
95
+ * True for a bare routed model id (a router announcement or, under a virtual selection, the
96
+ * message's `model`) rather than a provider-reported value. A weak value never displaces a
97
+ * decorated form of itself the provider already reported, such as a dated name.
98
+ */
99
+ weak: boolean;
100
+ /** The message's own provider when `raw` is its `model`, to recognize a failed route. */
101
+ provider?: string;
102
+ backend?: string;
103
+ routedId?: string;
104
+ }
105
+
84
106
  interface SessionState {
85
107
  startTime: number;
86
108
  workingText: WorkingTextSelection;
@@ -92,6 +114,12 @@ interface SessionState {
92
114
  lastTokenRateSampledAt: number;
93
115
  lastTokenRateTotal: number;
94
116
  responseModel: string;
117
+ served: { model: string; backend: string; routedId: string } | null;
118
+ /** The backend companion to the last raw response-model report. */
119
+ lastResponseModelBackend: string | undefined | typeof NOT_SENT;
120
+ /** The tier reported by the in-flight provider call, added to counts at its assistant message_end. */
121
+ pendingServiceTier: string;
122
+ serviceTiers: ServiceTierCount[];
95
123
  /** Last raw responseModel value seen, or the NOT_SENT sentinel before the first call. */
96
124
  lastResponseModelRaw: unknown;
97
125
  responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
@@ -134,6 +162,10 @@ function makeFreshState(): SessionState {
134
162
  lastTokenRateSampledAt: 0,
135
163
  lastTokenRateTotal: 0,
136
164
  responseModel: "",
165
+ served: null,
166
+ lastResponseModelBackend: NOT_SENT,
167
+ pendingServiceTier: "",
168
+ serviceTiers: [],
137
169
  lastResponseModelRaw: NOT_SENT,
138
170
  responseModelHoldTimer: null,
139
171
  responseModelFadeTimer: null,
@@ -155,10 +187,6 @@ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
155
187
  return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
156
188
  }
157
189
 
158
- function responseModelOf(message: unknown): unknown {
159
- return isPlainObject(message) ? message.responseModel : undefined;
160
- }
161
-
162
190
  /** Owns mutable extension state and Pi lifecycle registrations. */
163
191
  export class SessionManager {
164
192
  #counter = new StreamingWordCounter();
@@ -246,7 +274,7 @@ export class SessionManager {
246
274
  if (this.usable(ctx) && event.message.role === "assistant") {
247
275
  this.#state.liveTokens = 0;
248
276
  this.#counter.reset();
249
- this.updateResponseModel(responseModelOf(event.message));
277
+ this.updateResponseModel(this.responseModelOf(event.message));
250
278
  }
251
279
  };
252
280
 
@@ -260,14 +288,15 @@ export class SessionManager {
260
288
  ) {
261
289
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
262
290
  }
263
- const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
264
- ?? responseModelOf(event.message);
291
+ const rawResponseModel = this.responseModelOf((assistantEvent as { partial?: unknown } | undefined)?.partial)
292
+ ?? this.responseModelOf(event.message);
265
293
  this.updateResponseModel(rawResponseModel);
266
294
  };
267
295
 
268
296
  #onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
269
297
  this.#currentCtx = ctx;
270
298
  if (!this.usable(ctx) || event.message.role !== "assistant") return;
299
+ this.commitServiceTierCall();
271
300
 
272
301
  const rawTokens = event.message.usage?.output;
273
302
  const exactTokens =
@@ -282,9 +311,25 @@ export class SessionManager {
282
311
  }
283
312
  this.#state.liveTokens = 0;
284
313
  this.#counter.reset();
285
- this.updateResponseModel(responseModelOf(event.message));
314
+ this.updateResponseModel(this.responseModelOf(event.message));
286
315
  };
287
316
 
317
+ #onProviderStreamEvent = (event: ProviderStreamEvent, ctx: ExtensionContext): void => {
318
+ if (!this.usable(ctx) || !this.#state.busy) return;
319
+ const tier = serviceTierOf(event.data);
320
+ if (tier) this.#state.pendingServiceTier = tier;
321
+ const served = servedModelOf(event.data);
322
+ if (served) {
323
+ this.updateResponseModel({ raw: served.model, weak: false, provider: event.provider, backend: served.backend });
324
+ }
325
+ };
326
+
327
+ private commitServiceTierCall(): void {
328
+ if (!this.#state.pendingServiceTier) return;
329
+ recordServiceTierCall(this.#state.serviceTiers, this.#state.pendingServiceTier);
330
+ this.#state.pendingServiceTier = "";
331
+ }
332
+
288
333
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
289
334
  this.#currentCtx = ctx;
290
335
  if (this.usable(ctx)) {
@@ -314,6 +359,7 @@ export class SessionManager {
314
359
  #onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
315
360
  this.#currentCtx = ctx;
316
361
  if (!this.usable(ctx)) return;
362
+ this.commitServiceTierCall();
317
363
 
318
364
  const hadPrompt = !!this.#state.startTime;
319
365
  const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
@@ -338,6 +384,7 @@ export class SessionManager {
338
384
  elapsedMs,
339
385
  tokens: this.#state.confirmTokens,
340
386
  midTurnInputs: this.#state.midTurnInputs,
387
+ ...(this.#state.serviceTiers.length ? { serviceTiers: this.#state.serviceTiers.map((count) => ({ ...count })) } : {}),
341
388
  thinkingLevel: ctx.thinkingLevel,
342
389
  model: this.#state.responseModel || undefined,
343
390
  });
@@ -362,9 +409,13 @@ export class SessionManager {
362
409
  if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && Number.isInteger(entry.data.midTurnInputs) && entry.data.midTurnInputs > 0) {
363
410
  details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
364
411
  }
412
+ if (features.doneMarkerServiceTier) {
413
+ const summary = formatServiceTierSummary(serviceTierCountsOf(entry.data.serviceTiers));
414
+ if (summary) details.push(summary);
415
+ }
365
416
  const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
366
417
  const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
367
- const rawModel = typeof entry.data.model === "string" ? stripControlChars(entry.data.model).trim() : "";
418
+ const rawModel = sanitizedText(entry.data.model);
368
419
  const modelColored = features.doneMarkerModel && rawModel
369
420
  ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
370
421
  : "";
@@ -399,6 +450,7 @@ export class SessionManager {
399
450
  this.#pi.on("message_start", this.#onMessageStart);
400
451
  this.#pi.on("message_update", this.#onMessageUpdate);
401
452
  this.#pi.on("message_end", this.#onMessageEnd);
453
+ this.#pi.on("provider_stream_event", this.#onProviderStreamEvent);
402
454
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
403
455
  this.#pi.on("agent_settled", this.#onAgentSettled);
404
456
  this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
@@ -407,6 +459,13 @@ export class SessionManager {
407
459
  this.#pi.events.on(STATUSLINE_EMBED_CHANNEL, (data) => {
408
460
  this.#loaderInStatusline = announcesEmbedded(data);
409
461
  });
462
+ this.#pi.events.on(ROUTED_MODEL_CHANNEL, (data) => {
463
+ // Direct routes serve compaction and other extensions' side requests; a route while idle
464
+ // is such a background request too, and must not leave a stale model for the next turn.
465
+ const announcement = routedModelAnnouncement(data);
466
+ if (!announcement || announcement.reason === "direct" || !this.#state.busy) return;
467
+ this.updateResponseModel({ raw: announcement.modelId, weak: true, provider: announcement.provider, routedId: announcement.modelId });
468
+ });
410
469
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
411
470
  this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
412
471
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
@@ -475,25 +534,63 @@ export class SessionManager {
475
534
  }
476
535
  }
477
536
 
478
- private updateResponseModel(raw: unknown): void {
479
- if (raw === this.#state.lastResponseModelRaw) return;
480
- this.#state.lastResponseModelRaw = raw;
481
- const responseModel = typeof raw === "string" ? stripControlChars(raw).trim() : "";
482
- const selectedModel = this.#currentCtx?.model?.id;
483
- if (responseModel && modelsResemble(selectedModel, responseModel)) {
537
+ /**
538
+ * The response model a message reports. Under a virtual selection Pi stamps the routed
539
+ * physical model on the message's `model` instead, so fall back to that as a weak value;
540
+ * a failed route leaves the virtual model itself there, which `restatesSelection` hides.
541
+ */
542
+ private responseModelOf(message: unknown): ResponseModelReport | undefined {
543
+ if (!isPlainObject(message)) return undefined;
544
+ if (message.responseModel !== undefined) {
545
+ return { raw: message.responseModel, weak: false, routedId: sanitizedText(message.model) || undefined };
546
+ }
547
+ if (this.#currentCtx?.model?.api !== VIRTUAL_MODEL_API) return undefined;
548
+ return { raw: message.model, weak: true, provider: typeof message.provider === "string" ? message.provider : undefined };
549
+ }
550
+
551
+ /**
552
+ * Whether a response model merely restates the selected model. Under a virtual selection
553
+ * the routed model is always another model, so only a failed route, which leaves the
554
+ * virtual model itself on the message, is hidden; a resemblance check would also hide a
555
+ * routed model whose id contains the virtual id, such as openrouter's `auto`.
556
+ */
557
+ private restatesSelection(report: ResponseModelReport, responseModel: string): boolean {
558
+ const selected = this.#currentCtx?.model;
559
+ if (!selected) return false;
560
+ if (selected.api === VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
561
+ return modelsResemble(selected.id, responseModel);
562
+ }
563
+
564
+ private updateResponseModel(report: ResponseModelReport | undefined): void {
565
+ const backendReport = report?.backend;
566
+ if (report?.raw === this.#state.lastResponseModelRaw && backendReport === this.#state.lastResponseModelBackend) return;
567
+ this.#state.lastResponseModelRaw = report?.raw;
568
+ this.#state.lastResponseModelBackend = backendReport;
569
+ if (!report) return;
570
+ const model = sanitizedText(report.raw);
571
+ if (!model) return;
572
+ const backend = report.backend ?? (model === this.#state.served?.model ? this.#state.served.backend : "");
573
+ const showBackend = !!backend && backend !== report.provider;
574
+ const restates = this.restatesSelection(report, model);
575
+ const display = restates ? (showBackend ? `via ${backend}` : "") : (showBackend ? `${model} via ${backend}` : model);
576
+ if (!display) {
484
577
  if (this.#state.responseModel) {
485
578
  this.#state.responseModel = "";
486
579
  this.stopResponseModelSlide();
487
580
  this.tick();
488
581
  }
582
+ this.#state.served = null;
489
583
  return;
490
584
  }
491
- if (responseModel && responseModel !== this.#state.responseModel) {
585
+ if (report.weak && model === this.#state.served?.routedId) return;
586
+ const changed = display !== this.#state.responseModel;
587
+ if (changed) {
492
588
  // A model replacing one already shown swaps in place; only an appearing one slides out.
493
589
  if (!this.#state.responseModel) this.startResponseModelSlide();
494
- this.#state.responseModel = responseModel;
495
- this.tick();
590
+ this.#state.responseModel = display;
496
591
  }
592
+ this.#state.served = { model, backend, routedId: report.routedId ?? model };
593
+ if (changed) this.tick();
497
594
  }
498
595
 
499
596
  /**
@@ -601,7 +698,11 @@ export class SessionManager {
601
698
  state.confirmTokens = 0;
602
699
  state.liveTokens = 0;
603
700
  state.responseModel = "";
701
+ state.served = null;
702
+ state.pendingServiceTier = "";
703
+ state.serviceTiers = [];
604
704
  state.lastResponseModelRaw = NOT_SENT;
705
+ state.lastResponseModelBackend = NOT_SENT;
605
706
  state.activityMeter.reset();
606
707
  resetTokenRateState(state);
607
708
  state.midTurnInputs = 0;
package/src/settings.ts CHANGED
@@ -119,6 +119,7 @@ export interface DecoratorSettings {
119
119
  randomizeDoneMarker: boolean;
120
120
  doneMarkerTokens: boolean;
121
121
  doneMarkerInputs: boolean;
122
+ doneMarkerServiceTier: boolean;
122
123
  doneMarkerModel: boolean;
123
124
  includeDefaultWorkingText: boolean;
124
125
  };
@@ -128,7 +129,7 @@ export interface DecoratorSettings {
128
129
 
129
130
  export const DEFAULT_SETTINGS: DecoratorSettings = {
130
131
  decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "thinking-level", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "thinking-level", doneMarkerStyle: "elite", doneMarkerModelColor: "muted", doneMarkerModelDimmed: false, spinnerColor: "thinking-level", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, meterUpperBoundTps: DEFAULT_METER_UPPER_BOUND_TPS, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
131
- features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true, includeDefaultWorkingText: true },
132
+ features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerServiceTier: true, doneMarkerModel: true, includeDefaultWorkingText: true },
132
133
  loaderOrder: [...DEFAULT_LOADER_ORDER],
133
134
  wordPacks: {},
134
135
  };
@@ -231,6 +232,7 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
231
232
  { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
232
233
  { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
233
234
  { id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
235
+ { id: "doneMarkerServiceTier", label: "Service tier", section: "Completion Marker", group: "features", key: "doneMarkerServiceTier" },
234
236
  { id: "doneMarkerModel", label: "Response model", section: "Completion Marker", group: "features", key: "doneMarkerModel" },
235
237
  { id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
236
238
  { id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" },
package/src/util.ts CHANGED
@@ -8,6 +8,10 @@ export function stripControlChars(text: string): string {
8
8
  return text.replace(/[\p{Cc}\p{Cf}]/gu, "");
9
9
  }
10
10
 
11
+ export function sanitizedText(value: unknown): string {
12
+ return typeof value === "string" ? stripControlChars(value).trim() : "";
13
+ }
14
+
11
15
  /** True when a reported response model appears to be a decorated form of the selected model. */
12
16
  export function modelsResemble(selectedModel?: string, responseModel?: string): boolean {
13
17
  if (!selectedModel || !responseModel) return false;