@underactive/pi-topping 0.6.3 → 0.6.4

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,7 +6,7 @@ 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 (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, and live output-token rate (with color choice and dim toggle) — 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 (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, live output-token rate (with color choice and dim toggle), and the response model (with visibility, color, and dim controls) — 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.
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
 
@@ -23,7 +23,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
23
23
  ╔═[ Pi Topping: Settings ]═══════════════════════════════════╗
24
24
  ╟─ Preview ──────────────────────────────────────────────────╢
25
25
  ║ ║
26
- ║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tok/s · 3s · ↓ 84 tokens ║
26
+ ║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
27
27
  ║ ║
28
28
  ╟─ User Prompt ──────────────────────────────────────────────╢
29
29
  ║ [■] High-vis prompt ON ║
@@ -51,6 +51,9 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
51
51
  ║ [■] Token rate ON ║
52
52
  ║ [■] Token rate color ‹ warning › ║
53
53
  ║ [■] Token rate dimmed OFF ║
54
+ ║ [■] Response model ON ║
55
+ ║ [■] Response model color ‹ accent › ║
56
+ ║ [■] Response model dimmed OFF ║
54
57
  ║ ║
55
58
  ╟─ Elements Order ───────────────────────────────────────────╢
56
59
  ║ [ ] Animated spinner ║
@@ -59,6 +62,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
59
62
  ║ [ ] Token rate ║
60
63
  ║ [ ] Elapsed time ║
61
64
  ║ [ ] Output tokens ║
65
+ ║ [ ] Response model ║
62
66
  ║ ║
63
67
  ╟─ Completion Marker ────────────────────────────────────────╢
64
68
  ║ [■] Show completion marker ON ║
@@ -84,9 +88,9 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
84
88
  ╟─ Options ──────────────────────────────────────────────────╢
85
89
  ║ [■] Use NerdFont icons ON ║
86
90
  ╟────────────────────────────────────────────────────────────╢
87
- ║ ↑↓ move PgUp/PgDn page ←→ select ║
88
- ║ ␣ toggle ⏎ apply esc cancel ║
89
- ╚════════════════════════════════════════════════════[ 9/47 ]╝
91
+ ║ ↑↓ move PgUp/PgDn page ←→ select ║
92
+ ║ ␣ toggle ⏎ apply esc cancel ║
93
+ ╚════════════════════════════════════════════════════[ 9/50 ]╝
90
94
  ```
91
95
 
92
96
  ### Word packs
@@ -108,17 +112,19 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
108
112
  Pack IDs must start with a lowercase letter and contain only lowercase letters, digits, and `-`. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
109
113
 
110
114
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
111
- left or right within the loader. Elapsed time, output tokens, and token rate are joined with
115
+ left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
112
116
  `·` separators whenever adjacent. When reordering separates them, each adjacent run is rendered
113
117
  without a separator before or after it.
114
118
 
115
- The `N tok/s` token rate uses the selected theme color and
119
+ The `N tps` token rate uses the selected theme color and
116
120
  remains reorderable. Active rates below 1,000 are padded to three characters so updates do not shift the other
117
- segments; larger rates may shift later segments. When inactive, it remains as the dim `--- tok/s` placeholder. After its last update it holds
121
+ segments; larger rates may shift later segments. When inactive, it remains as the dim `--- tps` placeholder. After its last update it holds
118
122
  full brightness for 1.5 seconds, then fades through five theme-aware shades to the dim text color over
119
123
  the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
120
124
  restarts the cycle.
121
125
 
126
+ The response model is captured from assistant responses, displayed as its sanitized value with no label, and defaults to the final loader detail after output tokens. It can be hidden, recolored, permanently dimmed, or reordered. After the agent settles, its final value remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears; new work cancels the pending fade.
127
+
122
128
  ## Install
123
129
 
124
130
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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",
package/src/format.ts CHANGED
@@ -19,13 +19,13 @@ export const SHIMMER_INTERVAL_MS = 50;
19
19
  export const ELAPSED_INTERVAL_MS = 1_000;
20
20
 
21
21
  /** A user-orderable piece of the working indicator. */
22
- export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens" | "tokenRate";
22
+ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens" | "tokenRate" | "responseModel";
23
23
 
24
24
  /** Default first-use working-indicator element order. */
25
- export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens"];
25
+ export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens", "responseModel"];
26
26
 
27
27
  /** Elements share a detail group; separators and non-rate details are dimmed. */
28
- const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate"]);
28
+ const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate", "responseModel"]);
29
29
 
30
30
  const TOKEN_UNITS = [
31
31
  { threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
@@ -52,12 +52,12 @@ export function formatTokens(count: number): string {
52
52
  const TOKEN_RATE_DIGITS = 3;
53
53
 
54
54
  /** Placeholder shown when the working indicator has no active token-rate sample. */
55
- export const TOKEN_RATE_PLACEHOLDER = `${"-".repeat(TOKEN_RATE_DIGITS)} tok/s`;
55
+ export const TOKEN_RATE_PLACEHOLDER = `${"-".repeat(TOKEN_RATE_DIGITS)} tps`;
56
56
 
57
57
  /** Format an output-token throughput estimate for the working indicator. */
58
58
  export function formatTokenRate(rate: number): string {
59
59
  const rounded = Math.round(rate);
60
- return rounded === 0 ? "" : `${rounded.toString().padStart(TOKEN_RATE_DIGITS)} tok/s`;
60
+ return rounded === 0 ? "" : `${rounded.toString().padStart(TOKEN_RATE_DIGITS)} tps`;
61
61
  }
62
62
 
63
63
  /**
@@ -74,6 +74,12 @@ export function dimAttribute(text: string): string {
74
74
  /** Number of discrete, eased warning-to-dim shades used for the token-rate fade. */
75
75
  export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
76
76
 
77
+ /** Milliseconds a settled response model remains fully visible before fading. */
78
+ export const RESPONSE_MODEL_HOLD_MS = 3_000;
79
+
80
+ /** Milliseconds over which a settled response model fades through the theme shades. */
81
+ export const RESPONSE_MODEL_FADE_MS = 500;
82
+
77
83
  /** Render one token-rate fade shade by blending the selected color toward the active theme's dim color. */
78
84
  export function fadeThemeColorString(
79
85
  text: string,
@@ -144,8 +150,8 @@ export function buildWorkingMessage(
144
150
  const value = parts[element];
145
151
  if (!value) continue;
146
152
  if (DETAIL_ELEMENTS.has(element)) {
147
- // tokenRate arrives pre-colored; dimming it here would replace its configured color.
148
- details.push(element === "tokenRate" ? value : theme.fg("dim", value));
153
+ // tokenRate and responseModel arrive pre-colored; dimming them here would replace their configured colors.
154
+ details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("dim", value));
149
155
  continue;
150
156
  }
151
157
  flushDetails();
package/src/preview.ts CHANGED
@@ -6,8 +6,8 @@ import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBox
6
6
  import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isDoneMarkerStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
7
7
  import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
8
8
  import { pickRandomWord } from "./words.ts";
9
- // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tok/s for the meter,
10
- // flat 28 tok/s for the token readouts.
9
+ // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
10
+ // flat 28 tps for the token readouts.
11
11
  const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
12
12
  const PROMPT_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "User Prompt").map(entry => entry.id));
13
13
  const MARKER_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "Completion Marker").map(entry => entry.id));
@@ -51,7 +51,7 @@ export class PreviewRenderer {
51
51
  this.#meter.push(rateToLevel(meterRate(elapsedMs)));
52
52
  this.#lastMeterUpdate = elapsedMs;
53
53
  }
54
- const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false };
54
+ const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
55
55
  const decorations = { shimmer: values.shimmer !== false, shimmerInverted: values.shimmerInverted === true, tokenActivityMonitor: values.tokenActivityMonitor !== false };
56
56
  let spinnerColor = DEFAULT_SETTINGS.decorations.spinnerColor;
57
57
  if (values.spinnerColorEnabled !== false && isSettingColor(values.spinnerColor)) {
@@ -62,8 +62,12 @@ export class PreviewRenderer {
62
62
  spinner = this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
63
63
  }
64
64
  const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
65
+ let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
66
+ if (isSettingColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
67
+ const responseModelColored = features.responseModel ? this.#ctx.ui.theme.fg(responseModelColor, "test-model") : "";
68
+ const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
65
69
  if (isFullyDefaultAppearance(features, decorations)) {
66
- return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, order);
70
+ return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, order);
67
71
  }
68
72
  let word = DEFAULT_WORKING_WORD;
69
73
  if (features.substituteDefaultMessage) {
@@ -121,6 +125,7 @@ export class PreviewRenderer {
121
125
  elapsed,
122
126
  tokens,
123
127
  tokenRate,
128
+ responseModel,
124
129
  }, order);
125
130
  }
126
131
  private packValues(values: Record<string, boolean | string>): Record<string, boolean> {
package/src/session.ts CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  formatTokens,
26
26
  isFullyDefaultAppearance,
27
27
  METER_INTERVAL_MS,
28
+ RESPONSE_MODEL_FADE_MS,
29
+ RESPONSE_MODEL_HOLD_MS,
28
30
  SHIMMER_INTERVAL_MS,
29
31
  shimmerString,
30
32
  SPINNER_FRAME_MS,
@@ -52,6 +54,7 @@ type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
52
54
  type UIPromptKind = UIPromptStartEvent["kind"];
53
55
 
54
56
  const DONE_ENTRY_TYPE = "pi-topping-done";
57
+ const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
55
58
  const WAITING_LABELS: Record<UIPromptKind, string> = {
56
59
  select: "Waiting for selection",
57
60
  confirm: "Waiting for confirmation",
@@ -82,6 +85,10 @@ interface SessionState {
82
85
  rateTracker: TokRateTracker;
83
86
  lastTokenRateSampledAt: number;
84
87
  lastTokenRateTotal: number;
88
+ responseModel: string;
89
+ responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
90
+ responseModelFadeTimer: ReturnType<typeof setInterval> | null;
91
+ responseModelFadeGeneration: number;
85
92
  tokenRateText: string;
86
93
  tokenRateFadeStartsAt: number;
87
94
  timer: ReturnType<typeof setInterval> | null;
@@ -115,6 +122,10 @@ function makeFreshState(): SessionState {
115
122
  rateTracker: new TokRateTracker(),
116
123
  lastTokenRateSampledAt: 0,
117
124
  lastTokenRateTotal: 0,
125
+ responseModel: "",
126
+ responseModelHoldTimer: null,
127
+ responseModelFadeTimer: null,
128
+ responseModelFadeGeneration: 0,
118
129
  tokenRateText: "",
119
130
  tokenRateFadeStartsAt: 0,
120
131
  timer: null,
@@ -145,13 +156,14 @@ export class SessionManager {
145
156
  }
146
157
 
147
158
  #onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
159
+ this.#currentCtx = ctx;
148
160
  this.stopTimer();
161
+ this.cancelResponseModelFade(ctx);
149
162
  this.#counter.reset();
150
163
  this.#state = makeFreshState();
151
164
  this.#settings = loadSettings();
152
165
  this.#userPacks = loadUserWordPacks();
153
166
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
154
- this.#currentCtx = ctx;
155
167
  if (this.usable(ctx)) {
156
168
  this.applyIndicator(ctx);
157
169
  if (isSiblingSetupEnabled()) notifyMissingToppingsOnce(this.#pi, ctx.ui);
@@ -165,32 +177,38 @@ export class SessionManager {
165
177
  if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
166
178
  else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
167
179
  if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
168
- this.#pi.sendMessage<PromptBoxDetails>(
169
- {
170
- customType: PROMPT_BOX_TYPE,
171
- content: event.text,
172
- display: true,
173
- details: {
174
- submittedAt: Date.now(),
175
- showIcon: this.#settings.decorations.promptIcon,
176
- showTimestamp: this.#settings.decorations.promptTimestamp,
177
- showProvider: this.#settings.decorations.promptProvider,
178
- showModel: this.#settings.decorations.promptModel,
179
- icon: this.#settings.decorations.useNerdFont ? "" : "π",
180
- provider: ctx.model?.provider,
181
- model: ctx.model?.id,
182
- borderColor: this.#settings.decorations.borderColor,
183
- borderStyle: this.#settings.decorations.borderStyle,
180
+ try {
181
+ await this.#pi.sendMessage<PromptBoxDetails>(
182
+ {
183
+ customType: PROMPT_BOX_TYPE,
184
+ content: event.text,
185
+ display: true,
186
+ details: {
187
+ submittedAt: Date.now(),
188
+ showIcon: this.#settings.decorations.promptIcon,
189
+ showTimestamp: this.#settings.decorations.promptTimestamp,
190
+ showProvider: this.#settings.decorations.promptProvider,
191
+ showModel: this.#settings.decorations.promptModel,
192
+ icon: this.#settings.decorations.useNerdFont ? "" : "π",
193
+ provider: ctx.model?.provider,
194
+ model: ctx.model?.id,
195
+ borderColor: this.#settings.decorations.borderColor,
196
+ borderStyle: this.#settings.decorations.borderStyle,
197
+ },
184
198
  },
185
- },
186
- { triggerTurn: true },
187
- );
188
- return { action: "handled" };
199
+ { triggerTurn: true },
200
+ );
201
+ return { action: "handled" };
202
+ } catch {
203
+ ctx.ui.notify("Failed to submit prompt", "error");
204
+ return;
205
+ }
189
206
  }
190
207
  };
191
208
 
192
209
  #onAgentStart = async (_e: AgentStartEvent, ctx: ExtensionContext): Promise<void> => {
193
210
  this.#currentCtx = ctx;
211
+ this.cancelResponseModelFade(ctx);
194
212
  if (!this.usable(ctx)) return;
195
213
  if (!this.#state.startTime) this.resetTurn(Date.now());
196
214
  this.#state.busy = true;
@@ -203,6 +221,7 @@ export class SessionManager {
203
221
  if (this.usable(ctx) && event.message.role === "assistant") {
204
222
  this.#state.liveTokens = 0;
205
223
  this.#counter.reset();
224
+ this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
206
225
  }
207
226
  };
208
227
 
@@ -216,13 +235,20 @@ export class SessionManager {
216
235
  ) {
217
236
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
218
237
  }
238
+ if (this.usable(ctx)) {
239
+ const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
240
+ ?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
241
+ this.updateResponseModel(rawResponseModel);
242
+ }
219
243
  };
220
244
 
221
245
  #onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
222
246
  this.#currentCtx = ctx;
223
247
  if (!this.usable(ctx) || event.message.role !== "assistant") return;
224
248
 
225
- const exactTokens = event.message.usage?.output;
249
+ const rawTokens = event.message.usage?.output;
250
+ const exactTokens =
251
+ typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens >= 0 ? rawTokens : undefined;
226
252
  this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
227
253
  if (exactTokens !== undefined) {
228
254
  // Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
@@ -233,6 +259,7 @@ export class SessionManager {
233
259
  }
234
260
  this.#state.liveTokens = 0;
235
261
  this.#counter.reset();
262
+ this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
236
263
  };
237
264
 
238
265
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
@@ -267,13 +294,17 @@ export class SessionManager {
267
294
 
268
295
  const hadPrompt = !!this.#state.startTime;
269
296
  const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
297
+ const responseModel = this.#settings.features.responseModel ? this.#state.responseModel : "";
298
+ const responseModelColor = this.#settings.decorations.responseModelColor;
299
+ const responseModelDimmed = this.#settings.decorations.responseModelDimmed;
270
300
  this.#state.busy = false;
271
301
  this.#state.waiting = null;
272
302
  this.#state.startTime = 0;
273
303
  this.#counter.reset();
274
304
  this.stopTimer();
275
- ctx.ui.setWorkingMessage();
276
305
  this.applyIndicator(ctx);
306
+ ctx.ui.setWorkingMessage();
307
+ if (responseModel) this.startResponseModelFade(ctx, responseModel, responseModelColor, responseModelDimmed);
277
308
  this.#state.activityMeter.reset();
278
309
  resetTokenRateState(this.#state);
279
310
  this.#state.lastMessage = NOT_SENT;
@@ -288,8 +319,9 @@ export class SessionManager {
288
319
  this.#currentCtx = null;
289
320
  };
290
321
 
291
- #onSessionShutdown = async (_e: SessionShutdownEvent, _ctx: ExtensionContext): Promise<void> => {
322
+ #onSessionShutdown = async (_e: SessionShutdownEvent, ctx: ExtensionContext): Promise<void> => {
292
323
  this.#state.waiting = null;
324
+ this.cancelResponseModelFade(ctx);
293
325
  this.stopTimer();
294
326
  };
295
327
 
@@ -413,6 +445,58 @@ export class SessionManager {
413
445
  }
414
446
  }
415
447
 
448
+ private updateResponseModel(raw: unknown): void {
449
+ const responseModel = typeof raw === "string" ? stripControlChars(raw).trim() : "";
450
+ if (responseModel && responseModel !== this.#state.responseModel) {
451
+ this.#state.responseModel = responseModel;
452
+ this.tick();
453
+ }
454
+ }
455
+
456
+ private cancelResponseModelFade(ctx?: ExtensionContext | null): void {
457
+ const state = this.#state;
458
+ state.responseModelFadeGeneration++;
459
+ if (state.responseModelHoldTimer) {
460
+ clearTimeout(state.responseModelHoldTimer);
461
+ state.responseModelHoldTimer = null;
462
+ }
463
+ if (state.responseModelFadeTimer) {
464
+ clearInterval(state.responseModelFadeTimer);
465
+ state.responseModelFadeTimer = null;
466
+ }
467
+ const activeCtx = ctx ?? this.#currentCtx;
468
+ if (activeCtx && this.usable(activeCtx)) {
469
+ activeCtx.ui.setStatus(RESPONSE_MODEL_STATUS_KEY, undefined);
470
+ }
471
+ }
472
+
473
+ private startResponseModelFade(ctx: ExtensionContext, model: string, color: SettingColor, dimmed: boolean): void {
474
+ this.cancelResponseModelFade(ctx);
475
+ const generation = this.#state.responseModelFadeGeneration;
476
+ const render = (shade?: number): void => {
477
+ const colored = shade === undefined
478
+ ? ctx.ui.theme.fg(color, model)
479
+ : fadeThemeColorString(model, shade, ctx.ui.theme, color);
480
+ const responseModel = dimmed ? dimAttribute(colored) : colored;
481
+ ctx.ui.setStatus(RESPONSE_MODEL_STATUS_KEY, responseModel);
482
+ };
483
+ render();
484
+ this.#state.responseModelHoldTimer = setTimeout(() => {
485
+ if (this.#state.responseModelFadeGeneration !== generation) return;
486
+ this.#state.responseModelHoldTimer = null;
487
+ let shade = 0;
488
+ render(shade++);
489
+ this.#state.responseModelFadeTimer = setInterval(() => {
490
+ if (this.#state.responseModelFadeGeneration !== generation) return;
491
+ if (shade >= TOKEN_RATE_FADE_SHADE_COUNT) {
492
+ this.cancelResponseModelFade(ctx);
493
+ return;
494
+ }
495
+ render(shade++);
496
+ }, RESPONSE_MODEL_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT);
497
+ }, RESPONSE_MODEL_HOLD_MS);
498
+ }
499
+
416
500
  private startTimer(): void {
417
501
  if (this.#state.timer) return;
418
502
 
@@ -431,12 +515,14 @@ export class SessionManager {
431
515
  }
432
516
 
433
517
  private resetTurn(now: number): void {
518
+ this.cancelResponseModelFade();
434
519
  const state = this.#state;
435
520
  state.startTime = now;
436
521
  state.shimmerOrigin = now;
437
522
  state.workingText = this.pickWorkingWord();
438
523
  state.confirmTokens = 0;
439
524
  state.liveTokens = 0;
525
+ state.responseModel = "";
440
526
  state.activityMeter.reset();
441
527
  resetTokenRateState(state);
442
528
  state.midTurnInputs = 0;
@@ -473,9 +559,13 @@ export class SessionManager {
473
559
  const spinner = this.spinnerInMessage()
474
560
  ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
475
561
  : "";
562
+ const responseModelColored = features.responseModel && state.responseModel
563
+ ? ctx.ui.theme.fg(decorations.responseModelColor, state.responseModel)
564
+ : "";
565
+ const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
476
566
  if (isFullyDefaultAppearance(features, decorations)) {
477
- const msg = spinner
478
- ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
567
+ const msg = spinner || responseModel
568
+ ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, this.#settings.loaderOrder)
479
569
  : undefined;
480
570
  if (msg !== state.lastMessage) {
481
571
  state.lastMessage = msg;
@@ -532,6 +622,7 @@ export class SessionManager {
532
622
  elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
533
623
  tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
534
624
  tokenRate: tokenRateStyled,
625
+ responseModel,
535
626
  },
536
627
  this.#settings.loaderOrder,
537
628
  );
package/src/settings.ts CHANGED
@@ -59,6 +59,8 @@ export interface DecoratorSettings {
59
59
  meterDimmed: boolean;
60
60
  tokenRateColor: SettingColor;
61
61
  tokenRateDimmed: boolean;
62
+ responseModelColor: SettingColor;
63
+ responseModelDimmed: boolean;
62
64
  promptIcon: boolean;
63
65
  promptTimestamp: boolean;
64
66
  promptProvider: boolean;
@@ -70,6 +72,7 @@ export interface DecoratorSettings {
70
72
  elapsedTime: boolean;
71
73
  outputTokens: boolean;
72
74
  tokenRate: boolean;
75
+ responseModel: boolean;
73
76
  doneMarker: boolean;
74
77
  doneMarkerIcon: boolean;
75
78
  randomizeDoneMarker: boolean;
@@ -81,8 +84,8 @@ export interface DecoratorSettings {
81
84
  }
82
85
 
83
86
  export const DEFAULT_SETTINGS: DecoratorSettings = {
84
- decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", doneMarkerStyle: "elite", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
85
- features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
87
+ decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", doneMarkerStyle: "elite", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
88
+ features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
86
89
  loaderOrder: [...DEFAULT_LOADER_ORDER],
87
90
  wordPacks: {},
88
91
  };
@@ -96,6 +99,7 @@ const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
96
99
  elapsed: "Elapsed time",
97
100
  tokens: "Output tokens",
98
101
  tokenRate: "Token rate",
102
+ responseModel: "Response model",
99
103
  };
100
104
 
101
105
  /**
@@ -198,6 +202,10 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
198
202
  { id: "showTokenRate", label: "Token rate", section: "“Working” Loader", group: "features", key: "tokenRate" },
199
203
  { id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: SETTING_COLOR_VALUES },
200
204
  { id: "tokenRateDimmed", label: "Token rate dimmed", section: "“Working” Loader", group: "decorations", key: "tokenRateDimmed" },
205
+ // id differs from key: the Elements Order row already owns "responseModel" in the menu's shared value namespace.
206
+ { id: "showResponseModel", label: "Response model", section: "“Working” Loader", group: "features", key: "responseModel" },
207
+ { id: "responseModelColor", label: "Response model color", section: "“Working” Loader", group: "decorations", key: "responseModelColor", cycleValues: SETTING_COLOR_VALUES },
208
+ { id: "responseModelDimmed", label: "Response model dimmed", section: "“Working” Loader", group: "decorations", key: "responseModelDimmed" },
201
209
  { id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
202
210
  { id: "doneMarkerStyle", label: "Marker style", section: "Completion Marker", group: "decorations", key: "doneMarkerStyle", cycleValues: DONE_MARKER_STYLE_VALUES },
203
211
  { id: "doneMarkerBorderStyle", label: "Border style", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderStyle", cycleValues: DONE_MARKER_BORDER_STYLE_VALUES },
@@ -228,6 +236,7 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
228
236
  case "spinnerColor":
229
237
  case "meterColor":
230
238
  case "tokenRateColor":
239
+ case "responseModelColor":
231
240
  case "doneMarkerBorderColor":
232
241
  if (isSettingColor(stored)) decorations[key] = stored;
233
242
  return;