@underactive/pi-topping 0.6.2 → 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.
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,8 +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 ←→ select ␣ toggle ⏎ apply esc cancel ║
88
- ╚════════════════════════════════════════════════════[ 9/47 ]╝
91
+ ║ ↑↓ move PgUp/PgDn page ←→ select ║
92
+ ║ ␣ toggle ⏎ apply esc cancel ║
93
+ ╚════════════════════════════════════════════════════[ 9/50 ]╝
89
94
  ```
90
95
 
91
96
  ### Word packs
@@ -107,17 +112,19 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
107
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.
108
113
 
109
114
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
110
- 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
111
116
  `·` separators whenever adjacent. When reordering separates them, each adjacent run is rendered
112
117
  without a separator before or after it.
113
118
 
114
- The `N tok/s` token rate uses the selected theme color and
119
+ The `N tps` token rate uses the selected theme color and
115
120
  remains reorderable. Active rates below 1,000 are padded to three characters so updates do not shift the other
116
- 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
117
122
  full brightness for 1.5 seconds, then fades through five theme-aware shades to the dim text color over
118
123
  the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
119
124
  restarts the cycle.
120
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
+
121
128
  ## Install
122
129
 
123
130
  ```bash
@@ -128,7 +135,7 @@ Restart Pi (or run `/reload`) to pick it up.
128
135
 
129
136
  ## Requirements
130
137
 
131
- - [Pi](https://github.com/earendil-works/pi-coding-agent)
138
+ - [Pi](https://github.com/earendil-works/pi-coding-agent). Pi ≥0.84.4 is required for the waiting-for-input loader state; older Pi versions keep the animated loader.
132
139
  - Node.js ≥22.19.0
133
140
 
134
141
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.6.2",
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",
@@ -30,8 +30,8 @@
30
30
  "@earendil-works/pi-tui": "*"
31
31
  },
32
32
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.82.1",
34
- "@earendil-works/pi-tui": "0.82.1",
33
+ "@earendil-works/pi-coding-agent": "0.84.4",
34
+ "@earendil-works/pi-tui": "0.84.4",
35
35
  "@types/node": "^24.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },
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/menu.ts CHANGED
@@ -5,27 +5,14 @@
5
5
  *
6
6
  * Renders a titled box containing one or more sections of boolean toggle,
7
7
  * multi-value cycle, or drag-to-reorder items, plus an optional live-updating
8
- * preview section driven by a caller-supplied render callback, e.g.:
9
- *
10
- * ╔═[ Pi Topping: Settings ]════════════════════════╗
11
- * ╟─ Preview ────────────────────────────────────╢
12
- * ║ ║
13
- * ║ ⠋ Cerebrating… ⣤⣤⣤⣤ 28 tok/s · 3s · ↓ 84 tokens ║
14
- * ║ ║
15
- * ╟─ Decorations ───────────────────────────────────╢
16
- * ║ ❯ [■] Animated spinner ON ║
17
- * ║ [ ] "Working..." text shimmer OFF ║
18
- * ║ ║
19
- * ╟──────────────────────────────────────────────────╢
20
- * ║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
21
- * ╚══════════════════════════════════════════[ 1/2 ]═╝
8
+ * preview section driven by a caller-supplied render callback.
22
9
  *
23
10
  * Intended to be reused by any extension that needs a simple modal toggle
24
11
  * menu; it has no dependency on this extension's own settings shape.
25
12
  */
26
13
 
27
14
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
28
- import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { type Component, Key, matchesKey, truncateToWidth, type SizeValue, type TUI, visibleWidth } from "@earendil-works/pi-tui";
29
16
 
30
17
  export type MenuValue = boolean | string;
31
18
 
@@ -79,6 +66,8 @@ export interface MenuConfig {
79
66
  preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => string[] | PreviewResult;
80
67
  /** Optional heading for the preview block. Defaults to `Preview`. */
81
68
  previewTitle?: string;
69
+ /** Maximum overlay height in rows or as a percentage of the terminal height. */
70
+ maxHeight?: SizeValue;
82
71
  }
83
72
 
84
73
  export interface MenuResult<T> {
@@ -86,7 +75,7 @@ export interface MenuResult<T> {
86
75
  values: T;
87
76
  }
88
77
 
89
- const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
78
+ const DEFAULT_HINTS = ["\u2191\u2193 move", "PgUp/PgDn page", "\u2423 toggle", "\u23ce apply", "esc cancel"];
90
79
  const OVERLAY_WIDTH = "86%";
91
80
  export const DEFAULT_PREVIEW_WIDTH = 76;
92
81
  const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
@@ -141,6 +130,7 @@ export class MenuComponent implements Component {
141
130
  private readonly previewFn: MenuConfig["preview"];
142
131
  private readonly previewOrigin: number | undefined;
143
132
  private readonly tui: TUI | undefined;
133
+ private readonly maxHeight: SizeValue | undefined;
144
134
  private previewTimer: ReturnType<typeof setTimeout> | undefined;
145
135
  private previewNextRefreshMs: number | undefined;
146
136
  private disposed = false;
@@ -149,6 +139,7 @@ export class MenuComponent implements Component {
149
139
  private cachedWidth: number | undefined;
150
140
  private cachedRows: number | undefined;
151
141
  private cachedLines: string[] | undefined;
142
+ private pageItemCount: number | undefined;
152
143
 
153
144
  constructor(
154
145
  config: MenuConfig,
@@ -163,6 +154,7 @@ export class MenuComponent implements Component {
163
154
  this.hints = config.hints ?? DEFAULT_HINTS;
164
155
  this.previewTitle = config.previewTitle ?? "Preview";
165
156
  this.tui = tui;
157
+ this.maxHeight = config.maxHeight;
166
158
  this.values = buildInitialValues(config);
167
159
  this.flat = [];
168
160
  for (const [sectionIndex, section] of config.sections.entries()) {
@@ -203,7 +195,7 @@ export class MenuComponent implements Component {
203
195
 
204
196
  // Map input to a normalized key name.
205
197
  let mappedKey: string | undefined;
206
- for (const k of [Key.up, Key.down, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
198
+ for (const k of [Key.up, Key.down, Key.pageUp, Key.pageDown, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
207
199
  if (matchesKey(data, k)) {
208
200
  mappedKey = k;
209
201
  break;
@@ -214,14 +206,16 @@ export class MenuComponent implements Component {
214
206
  const keyActions: Record<string, () => void> = {
215
207
  [Key.up]: () => {
216
208
  if (this.moveGrabbedItem(-1)) return;
217
- this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
209
+ this.cursor = Math.max(0, this.cursor - 1);
218
210
  this.invalidate();
219
211
  },
220
212
  [Key.down]: () => {
221
213
  if (this.moveGrabbedItem(1)) return;
222
- this.cursor = (this.cursor + 1) % this.flat.length;
214
+ this.cursor = Math.min(this.flat.length - 1, this.cursor + 1);
223
215
  this.invalidate();
224
216
  },
217
+ [Key.pageUp]: () => this.moveByPage(-1),
218
+ [Key.pageDown]: () => this.moveByPage(1),
225
219
  [Key.left]: () => this.cycleCurrentValue(-1),
226
220
  [Key.right]: () => this.cycleCurrentValue(1),
227
221
  [Key.space]: () => {
@@ -258,9 +252,9 @@ export class MenuComponent implements Component {
258
252
  }
259
253
 
260
254
  /**
261
- * Move the grabbed reorder row one slot within its group, clamped at the group
262
- * edges. Returns true when the key belongs to the mover and must not also move
263
- * the cursor — which is what keeps a grab from escaping its group.
255
+ * Move the grabbed reorder row within its group, clamped at the group edges.
256
+ * Returns true when the key belongs to the mover and must not also move the
257
+ * cursor — which is what keeps a grab from escaping its group.
264
258
  */
265
259
  private moveGrabbedItem(delta: number): boolean {
266
260
  const grabbed = this.flat[this.cursor]!;
@@ -272,10 +266,10 @@ export class MenuComponent implements Component {
272
266
  let end = this.cursor;
273
267
  while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
274
268
 
275
- const target = this.cursor + delta;
276
- if (target >= start && target <= end) {
277
- this.flat[this.cursor] = this.flat[target]!;
278
- this.flat[target] = grabbed;
269
+ const target = Math.max(start, Math.min(end, this.cursor + delta));
270
+ if (target !== this.cursor) {
271
+ this.flat.splice(this.cursor, 1);
272
+ this.flat.splice(target, 0, grabbed);
279
273
  this.cursor = target;
280
274
  this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
281
275
  this.invalidate();
@@ -283,6 +277,20 @@ export class MenuComponent implements Component {
283
277
  return true;
284
278
  }
285
279
 
280
+ private moveByPage(direction: -1 | 1): void {
281
+ const pageItemCount = this.getPageItemCount();
282
+ if (this.moveGrabbedItem(direction * pageItemCount)) return;
283
+ this.cursor = Math.max(0, Math.min(this.flat.length - 1, this.cursor + direction * pageItemCount));
284
+ this.invalidate();
285
+ }
286
+
287
+ private getPageItemCount(): number {
288
+ if (this.pageItemCount !== undefined) return this.pageItemCount;
289
+ const rows = this.availableRows();
290
+ if (rows === undefined) return Math.max(1, Math.min(this.flat.length, 10));
291
+ return Math.max(1, rows - 6);
292
+ }
293
+
286
294
  private cycleCurrentValue(delta: number): void {
287
295
  const item = this.flat[this.cursor]!;
288
296
  if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
@@ -377,10 +385,17 @@ export class MenuComponent implements Component {
377
385
  private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
378
386
  if (allLines.length <= maxRows) {
379
387
  this.scrollStart = 0;
388
+ this.pageItemCount = Math.max(1, this.flat.length);
380
389
  return allLines;
381
390
  }
382
- if (maxRows <= 0) return [];
383
- if (maxRows === 1) return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
391
+ if (maxRows <= 0) {
392
+ this.pageItemCount = 1;
393
+ return [];
394
+ }
395
+ if (maxRows === 1) {
396
+ this.pageItemCount = 1;
397
+ return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
398
+ }
384
399
 
385
400
  const contentRows = maxRows - 1; // Reserve one fixed row for scroll status.
386
401
  if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
@@ -405,6 +420,7 @@ export class MenuComponent implements Component {
405
420
  }
406
421
  }
407
422
  const window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
423
+ this.pageItemCount = Math.max(1, window.end - this.scrollStart + 1);
408
424
 
409
425
  const rows = [...window.lines];
410
426
  while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
@@ -417,8 +433,16 @@ export class MenuComponent implements Component {
417
433
  }
418
434
 
419
435
  private availableRows(): number | undefined {
420
- const rows = (this.tui as (TUI & { terminal?: { rows?: number } }) | undefined)?.terminal?.rows;
421
- return typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : undefined;
436
+ const rows = this.tui?.terminal?.rows;
437
+ if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) return undefined;
438
+
439
+ const terminalRows = Math.floor(rows);
440
+ if (this.maxHeight === undefined) return terminalRows;
441
+ const requestedRows = typeof this.maxHeight === "number"
442
+ ? this.maxHeight
443
+ : (Number.parseFloat(this.maxHeight) / 100) * terminalRows;
444
+ if (!Number.isFinite(requestedRows)) return terminalRows;
445
+ return Math.max(1, Math.min(terminalRows, Math.floor(requestedRows)));
422
446
  }
423
447
 
424
448
  private buildLines(maxWidth: number, maxRows?: number): string[] {
@@ -431,6 +455,7 @@ export class MenuComponent implements Component {
431
455
  const naturalBody = this.buildToggleSections(innerWidth);
432
456
  const naturalHeight = header.length + naturalBody.length + footer.length;
433
457
  if (maxRows === undefined || naturalHeight <= maxRows) {
458
+ this.pageItemCount = Math.max(1, this.flat.length);
434
459
  return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
435
460
  }
436
461
 
@@ -571,9 +596,10 @@ export async function showMenu<T extends Record<string, MenuValue>>(
571
596
  return { applied: false, values: initialValues };
572
597
  }
573
598
 
599
+ const maxHeight = config.maxHeight ?? "100%";
574
600
  return ctx.ui.custom<MenuResult<T>>(
575
601
  (tui, theme, _keybindings, done) =>
576
602
  new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
577
- { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight: "100%" } },
603
+ { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight } },
578
604
  );
579
605
  }
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,
@@ -47,8 +49,21 @@ type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
47
49
  type MessageUpdateEvent = Extract<ExtensionEvent, { type: "message_update" }>;
48
50
  type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
49
51
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
52
+ type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
53
+ type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
54
+ type UIPromptKind = UIPromptStartEvent["kind"];
50
55
 
51
56
  const DONE_ENTRY_TYPE = "pi-topping-done";
57
+ const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
58
+ const WAITING_LABELS: Record<UIPromptKind, string> = {
59
+ select: "Waiting for selection",
60
+ confirm: "Waiting for confirmation",
61
+ input: "Waiting for input",
62
+ editor: "Waiting in editor",
63
+ custom: "Waiting for input",
64
+ };
65
+ const WAITING_PULSE_FRAMES = ["·", "•", "●", "•"];
66
+ const WAITING_PULSE_INTERVAL_MS = 120;
52
67
  // Reconciliation resets the EMA at message boundaries, so retain and then fade the last rate to avoid flicker.
53
68
  const TOKEN_RATE_HOLD_MS = 1_500;
54
69
  const TOKEN_RATE_FADE_MS = 250;
@@ -70,10 +85,15 @@ interface SessionState {
70
85
  rateTracker: TokRateTracker;
71
86
  lastTokenRateSampledAt: number;
72
87
  lastTokenRateTotal: number;
88
+ responseModel: string;
89
+ responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
90
+ responseModelFadeTimer: ReturnType<typeof setInterval> | null;
91
+ responseModelFadeGeneration: number;
73
92
  tokenRateText: string;
74
93
  tokenRateFadeStartsAt: number;
75
94
  timer: ReturnType<typeof setInterval> | null;
76
95
  busy: boolean;
96
+ waiting: { kind: UIPromptKind; title?: string } | null;
77
97
  midTurnInputs: number;
78
98
  /** Last string passed to setWorkingMessage(), or the NOT_SENT sentinel before the first call. */
79
99
  lastMessage: string | undefined | typeof NOT_SENT;
@@ -102,15 +122,25 @@ function makeFreshState(): SessionState {
102
122
  rateTracker: new TokRateTracker(),
103
123
  lastTokenRateSampledAt: 0,
104
124
  lastTokenRateTotal: 0,
125
+ responseModel: "",
126
+ responseModelHoldTimer: null,
127
+ responseModelFadeTimer: null,
128
+ responseModelFadeGeneration: 0,
105
129
  tokenRateText: "",
106
130
  tokenRateFadeStartsAt: 0,
107
131
  timer: null,
108
132
  busy: false,
133
+ waiting: null,
109
134
  midTurnInputs: 0,
110
135
  lastMessage: NOT_SENT,
111
136
  };
112
137
  }
113
138
 
139
+ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
140
+ const title = waiting.title ? stripControlChars(waiting.title).trim() : "";
141
+ return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
142
+ }
143
+
114
144
  /** Owns mutable extension state and Pi lifecycle registrations. */
115
145
  export class SessionManager {
116
146
  #counter = new StreamingWordCounter();
@@ -126,13 +156,14 @@ export class SessionManager {
126
156
  }
127
157
 
128
158
  #onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
159
+ this.#currentCtx = ctx;
129
160
  this.stopTimer();
161
+ this.cancelResponseModelFade(ctx);
130
162
  this.#counter.reset();
131
163
  this.#state = makeFreshState();
132
164
  this.#settings = loadSettings();
133
165
  this.#userPacks = loadUserWordPacks();
134
166
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
135
- this.#currentCtx = ctx;
136
167
  if (this.usable(ctx)) {
137
168
  this.applyIndicator(ctx);
138
169
  if (isSiblingSetupEnabled()) notifyMissingToppingsOnce(this.#pi, ctx.ui);
@@ -146,32 +177,38 @@ export class SessionManager {
146
177
  if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
147
178
  else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
148
179
  if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
149
- this.#pi.sendMessage<PromptBoxDetails>(
150
- {
151
- customType: PROMPT_BOX_TYPE,
152
- content: event.text,
153
- display: true,
154
- details: {
155
- submittedAt: Date.now(),
156
- showIcon: this.#settings.decorations.promptIcon,
157
- showTimestamp: this.#settings.decorations.promptTimestamp,
158
- showProvider: this.#settings.decorations.promptProvider,
159
- showModel: this.#settings.decorations.promptModel,
160
- icon: this.#settings.decorations.useNerdFont ? "" : "π",
161
- provider: ctx.model?.provider,
162
- model: ctx.model?.id,
163
- borderColor: this.#settings.decorations.borderColor,
164
- 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
+ },
165
198
  },
166
- },
167
- { triggerTurn: true },
168
- );
169
- 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
+ }
170
206
  }
171
207
  };
172
208
 
173
209
  #onAgentStart = async (_e: AgentStartEvent, ctx: ExtensionContext): Promise<void> => {
174
210
  this.#currentCtx = ctx;
211
+ this.cancelResponseModelFade(ctx);
175
212
  if (!this.usable(ctx)) return;
176
213
  if (!this.#state.startTime) this.resetTurn(Date.now());
177
214
  this.#state.busy = true;
@@ -184,6 +221,7 @@ export class SessionManager {
184
221
  if (this.usable(ctx) && event.message.role === "assistant") {
185
222
  this.#state.liveTokens = 0;
186
223
  this.#counter.reset();
224
+ this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
187
225
  }
188
226
  };
189
227
 
@@ -197,13 +235,20 @@ export class SessionManager {
197
235
  ) {
198
236
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
199
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
+ }
200
243
  };
201
244
 
202
245
  #onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
203
246
  this.#currentCtx = ctx;
204
247
  if (!this.usable(ctx) || event.message.role !== "assistant") return;
205
248
 
206
- 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;
207
252
  this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
208
253
  if (exactTokens !== undefined) {
209
254
  // Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
@@ -214,6 +259,7 @@ export class SessionManager {
214
259
  }
215
260
  this.#state.liveTokens = 0;
216
261
  this.#counter.reset();
262
+ this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
217
263
  };
218
264
 
219
265
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
@@ -225,18 +271,40 @@ export class SessionManager {
225
271
  }
226
272
  };
227
273
 
274
+ #onUIPromptStart = (event: UIPromptStartEvent, ctx: ExtensionContext): void => {
275
+ this.#currentCtx = ctx;
276
+ if (!this.usable(ctx) || !this.#state.busy) return;
277
+ this.#state.waiting = { kind: event.kind, title: event.title };
278
+ this.applyWaitingIndicator(ctx);
279
+ this.tick();
280
+ };
281
+
282
+ #onUIPromptEnd = (_event: UIPromptEndEvent, ctx: ExtensionContext): void => {
283
+ this.#currentCtx = ctx;
284
+ if (!this.usable(ctx) || !this.#state.waiting) return;
285
+ this.#state.waiting = null;
286
+ if (!this.#state.busy) return;
287
+ this.applyIndicator(ctx);
288
+ this.tick();
289
+ };
290
+
228
291
  #onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
229
292
  this.#currentCtx = ctx;
230
293
  if (!this.usable(ctx)) return;
231
294
 
232
295
  const hadPrompt = !!this.#state.startTime;
233
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;
234
300
  this.#state.busy = false;
301
+ this.#state.waiting = null;
235
302
  this.#state.startTime = 0;
236
303
  this.#counter.reset();
237
304
  this.stopTimer();
238
- ctx.ui.setWorkingMessage();
239
305
  this.applyIndicator(ctx);
306
+ ctx.ui.setWorkingMessage();
307
+ if (responseModel) this.startResponseModelFade(ctx, responseModel, responseModelColor, responseModelDimmed);
240
308
  this.#state.activityMeter.reset();
241
309
  resetTokenRateState(this.#state);
242
310
  this.#state.lastMessage = NOT_SENT;
@@ -251,7 +319,9 @@ export class SessionManager {
251
319
  this.#currentCtx = null;
252
320
  };
253
321
 
254
- #onSessionShutdown = async (_e: SessionShutdownEvent, _ctx: ExtensionContext): Promise<void> => {
322
+ #onSessionShutdown = async (_e: SessionShutdownEvent, ctx: ExtensionContext): Promise<void> => {
323
+ this.#state.waiting = null;
324
+ this.cancelResponseModelFade(ctx);
255
325
  this.stopTimer();
256
326
  };
257
327
 
@@ -298,6 +368,8 @@ export class SessionManager {
298
368
  this.#pi.on("message_end", this.#onMessageEnd);
299
369
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
300
370
  this.#pi.on("agent_settled", this.#onAgentSettled);
371
+ this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
372
+ this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
301
373
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
302
374
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
303
375
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
@@ -345,6 +417,13 @@ export class SessionManager {
345
417
  return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
346
418
  }
347
419
 
420
+ private applyWaitingIndicator(ctx: ExtensionContext): void {
421
+ ctx.ui.setWorkingIndicator({
422
+ frames: WAITING_PULSE_FRAMES.map((frame) => ctx.ui.theme.fg("dim", frame)),
423
+ intervalMs: WAITING_PULSE_INTERVAL_MS,
424
+ });
425
+ }
426
+
348
427
  private applyIndicator(ctx: ExtensionContext): void {
349
428
  if (!this.#settings.decorations.animatedSpinner || this.spinnerInMessage()) {
350
429
  ctx.ui.setWorkingIndicator({ frames: [] });
@@ -366,6 +445,58 @@ export class SessionManager {
366
445
  }
367
446
  }
368
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
+
369
500
  private startTimer(): void {
370
501
  if (this.#state.timer) return;
371
502
 
@@ -384,12 +515,14 @@ export class SessionManager {
384
515
  }
385
516
 
386
517
  private resetTurn(now: number): void {
518
+ this.cancelResponseModelFade();
387
519
  const state = this.#state;
388
520
  state.startTime = now;
389
521
  state.shimmerOrigin = now;
390
522
  state.workingText = this.pickWorkingWord();
391
523
  state.confirmTokens = 0;
392
524
  state.liveTokens = 0;
525
+ state.responseModel = "";
393
526
  state.activityMeter.reset();
394
527
  resetTokenRateState(state);
395
528
  state.midTurnInputs = 0;
@@ -401,6 +534,14 @@ export class SessionManager {
401
534
  const ctx = this.#currentCtx;
402
535
  const state = this.#state;
403
536
  if (!state.busy || !ctx) return;
537
+ if (state.waiting) {
538
+ const msg = ctx.ui.theme.fg("dim", waitingLabel(state.waiting));
539
+ if (msg !== state.lastMessage) {
540
+ state.lastMessage = msg;
541
+ ctx.ui.setWorkingMessage(msg);
542
+ }
543
+ return;
544
+ }
404
545
 
405
546
  const now = Date.now();
406
547
  const total = state.confirmTokens + state.liveTokens;
@@ -418,9 +559,13 @@ export class SessionManager {
418
559
  const spinner = this.spinnerInMessage()
419
560
  ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
420
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;
421
566
  if (isFullyDefaultAppearance(features, decorations)) {
422
- const msg = spinner
423
- ? 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)
424
569
  : undefined;
425
570
  if (msg !== state.lastMessage) {
426
571
  state.lastMessage = msg;
@@ -477,6 +622,7 @@ export class SessionManager {
477
622
  elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
478
623
  tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
479
624
  tokenRate: tokenRateStyled,
625
+ responseModel,
480
626
  },
481
627
  this.#settings.loaderOrder,
482
628
  );
@@ -497,8 +643,9 @@ export class SessionManager {
497
643
  const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
498
644
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
499
645
  title: "Pi Topping: Settings",
646
+ maxHeight: "75%",
500
647
  sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
501
- hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
648
+ hints: ["↑↓ move", "PgUp/PgDn page", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
502
649
  preview: preview.render.bind(preview),
503
650
  });
504
651
  if (!result.applied) return;
@@ -512,7 +659,10 @@ export class SessionManager {
512
659
  }
513
660
 
514
661
  this.#settings = updatedSettings;
515
- if (before !== this.indicatorFingerprint()) this.applyIndicator(ctx);
662
+ if (before !== this.indicatorFingerprint()) {
663
+ if (this.#state.waiting) this.applyWaitingIndicator(ctx);
664
+ else this.applyIndicator(ctx);
665
+ }
516
666
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
517
667
  if (this.#state.busy) {
518
668
  this.stopTimer();
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;
@@ -114,7 +114,7 @@ export function registerSetupCommand(pi: ExtensionAPI, install: ToppingInstaller
114
114
  })),
115
115
  },
116
116
  ],
117
- hints: ["↑↓ move", "␣ toggle", "⏎ install", "esc cancel"],
117
+ hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ install", "esc cancel"],
118
118
  previewTitle: "Description",
119
119
  preview: (_values, _elapsedMs, activeItemId) => ({ lines: renderDescription(missing, activeItemId) }),
120
120
  });