@underactive/pi-topping 0.7.1 → 0.8.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is a [Pi](https://github.com/earendil-works/pi-coding-agent) extension that adds some quality of life improvements to Pi's UX, while looking good in the process:
4
4
 
5
- **User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, timestamp, and provider/model label. Prompts submitted while Pi is working (steer / `Alt+Enter` follow-up) are left undecorated so Pi's native `Steering:` / `Follow-up:` queue rows still render.
5
+ **User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, timestamp, and provider/model label. The box, timestamp, and provider/model label are display-only; Pi sends your prompt text unchanged to the model, so they do not affect LLM prompt caching. Prompts submitted while Pi is working (steer / `Alt+Enter` follow-up) are left undecorated so Pi's native `Steering:` / `Follow-up:` queue rows still render.
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
 
@@ -10,7 +10,7 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
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. 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. 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
 
@@ -22,6 +22,10 @@ pi install npm:@underactive/pi-topping
22
22
 
23
23
  Restart Pi (or run `/reload`) to pick it up.
24
24
 
25
+ ### Sibling toppings
26
+
27
+ Run `/topping-setup` in TUI mode to selectively install missing Pi Topping sibling extensions or uninstall sibling packages recorded in Pi's user settings. Installation rows are selected by default; removal rows are unchecked by default. Restart Pi after applying package changes. Use `/topping-setup disable-side-toppings-check` to suppress missing-topping notices, or `enable-side-toppings-check` to restore them.
28
+
25
29
  ## Settings
26
30
 
27
31
  Run `/topping-settings` (TUI only) to customize your toppings. Settings persist to
@@ -61,7 +65,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
61
65
  ║ [■] Token rate dimmed OFF ║
62
66
  ║ [■] Response model ON ║
63
67
  ║ [■] Response model color ‹ accent › ║
64
- ║ [■] Response model dimmed OFF ║
68
+ ║ [■] Response model dimmed OFF ║
65
69
  ║ ║
66
70
  ╟─ Elements Order ──────────────────────────────────────────────╢
67
71
  ║ [ ] Animated spinner ║
@@ -81,6 +85,9 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
81
85
  ║ [■] Randomize “Worked” text ON ║
82
86
  ║ [■] Tokens spent ON ║
83
87
  ║ [■] Mid-turn inputs ON ║
88
+ ║ [■] Response model ON ║
89
+ ║ [■] Response model color ‹ muted › ║
90
+ ║ [■] Response model dimmed OFF ║
84
91
  ║ ║
85
92
  ╟─ Word Packs ──────────────────────────────────────────────────╢
86
93
  ║ [ ] Doctor Who OFF ║
@@ -98,7 +105,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
98
105
  ╟───────────────────────────────────────────────────────────────╢
99
106
  ║ ↑↓ move PgUp/PgDn page ←→ select ║
100
107
  ║ ␣ toggle ⏎ apply esc cancel ║
101
- ╚═══════════════════════════════════════════════════════[ 9/50 ]╝
108
+ ╚═══════════════════════════════════════════════════════[ 9/54 ]╝
102
109
  ```
103
110
 
104
111
  ### Word packs
@@ -134,7 +141,7 @@ full brightness for 1.5 seconds, then fades through five theme-aware shades to t
134
141
  the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
135
142
  restarts the cycle.
136
143
 
137
- 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. Equivalent local paths and decorated filenames are suppressed. 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.
144
+ 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. Equivalent local paths and decorated filenames are suppressed. It can be hidden, recolored, permanently dimmed, or reordered. When the active provider is `switchyard`, the loader and its post-settlement hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies. 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.
138
145
 
139
146
  ## Requirements
140
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.7.1",
3
+ "version": "0.8.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.84.4",
34
- "@earendil-works/pi-tui": "0.84.4",
33
+ "@earendil-works/pi-coding-agent": "0.86.0",
34
+ "@earendil-works/pi-tui": "0.86.0",
35
35
  "@types/node": "^24.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },
package/src/format.ts CHANGED
@@ -44,8 +44,13 @@ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens"
44
44
  /** Default first-use working-indicator element order. */
45
45
  export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens", "responseModel"];
46
46
 
47
- /** Elements share a detail group; separators are dimmed and non-rate details use muted. */
48
- const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate", "responseModel"]);
47
+ /** Elements share a detail group; "raw" details arrive pre-colored and must not be dimmed. */
48
+ const DETAIL_STYLES = new Map<LoaderElement, "muted" | "raw">([
49
+ ["elapsed", "muted"],
50
+ ["tokens", "muted"],
51
+ ["tokenRate", "raw"],
52
+ ["responseModel", "raw"],
53
+ ]);
49
54
 
50
55
  const TOKEN_UNITS = [
51
56
  { threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
@@ -169,9 +174,9 @@ export function buildWorkingMessage(
169
174
  for (const element of order) {
170
175
  const value = parts[element];
171
176
  if (!value) continue;
172
- if (DETAIL_ELEMENTS.has(element)) {
173
- // tokenRate and responseModel arrive pre-colored; dimming them here would replace their configured colors.
174
- details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("muted", value));
177
+ const style = DETAIL_STYLES.get(element);
178
+ if (style) {
179
+ details.push(style === "raw" ? value : theme.fg("muted", value));
175
180
  continue;
176
181
  }
177
182
  flushDetails();
@@ -0,0 +1,37 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getThinkingLevelColorizer, type ThinkingLevel } from "./format.ts";
3
+ import type { ThinkingLevelColor, ThinkingLevelSettingColor } from "./settings.ts";
4
+
5
+ /** Provider id whose working-loader response model uses NVIDIA green. */
6
+ export const SWITCHYARD_PROVIDER = "switchyard";
7
+
8
+ /** Bright NVIDIA green shared with pi-topping-statusline's Switchyard border. */
9
+ export const NVIDIA_GREEN_HEX = "#84c51a";
10
+
11
+ const NVIDIA_GREEN_RGB = [1, 3, 5].map((offset) => Number.parseInt(NVIDIA_GREEN_HEX.slice(offset, offset + 2), 16));
12
+ const NVIDIA_GREEN_ANSI = `\x1b[38;2;${NVIDIA_GREEN_RGB.join(";")}m`;
13
+
14
+ export function isSwitchyardProvider(provider: string | undefined): boolean {
15
+ return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
16
+ }
17
+
18
+ export function colorizeNvidiaGreen(text: string): string {
19
+ return `${NVIDIA_GREEN_ANSI}${text}\x1b[39m`;
20
+ }
21
+
22
+ /**
23
+ * Build the working-loader response-model colorizer.
24
+ *
25
+ * Switchyard always uses NVIDIA green; other providers retain the configured
26
+ * theme/thinking-level color.
27
+ */
28
+ export function getResponseModelColorizer(
29
+ theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
30
+ color: ThinkingLevelColor | ThinkingLevelSettingColor,
31
+ thinkingLevel: ThinkingLevel | undefined,
32
+ provider: string | undefined,
33
+ ): (text: string) => string {
34
+ return isSwitchyardProvider(provider)
35
+ ? colorizeNvidiaGreen
36
+ : getThinkingLevelColorizer(theme, color, thinkingLevel);
37
+ }
package/src/preview.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
3
  import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
4
4
  import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
5
+ import { getResponseModelColorizer } from "./nvidia-green.ts";
5
6
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
6
7
  import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
7
8
  import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
@@ -62,7 +63,7 @@ export class PreviewRenderer {
62
63
  const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
63
64
  let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
64
65
  if (isThinkingLevelColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
65
- const responseModelColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, responseModelColor, this.#ctx.thinkingLevel);
66
+ const responseModelColorizer = getResponseModelColorizer(this.#ctx.ui.theme, responseModelColor, this.#ctx.thinkingLevel, this.#ctx.model?.provider);
66
67
  const responseModelColored = features.responseModel ? responseModelColorizer("test-model") : "";
67
68
  const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
68
69
  if (isFullyDefaultAppearance(features, decorations)) {
@@ -173,7 +174,13 @@ export class PreviewRenderer {
173
174
  const details: string[] = [];
174
175
  if (values.doneMarkerTokens === true) details.push("↓ 949 tokens");
175
176
  if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
176
- const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details);
177
+ let model = "";
178
+ if (values.doneMarkerModel === true) {
179
+ const color = isThinkingLevelColor(values.doneMarkerModelColor) ? values.doneMarkerModelColor : DEFAULT_SETTINGS.decorations.doneMarkerModelColor;
180
+ const modelColored = getThinkingLevelColorizer(theme, color, this.#ctx.thinkingLevel)("test-model");
181
+ model = values.doneMarkerModelDimmed === true ? dimAttribute(modelColored) : modelColored;
182
+ }
183
+ const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details, model);
177
184
  const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
178
185
  const borderColor = isDoneMarkerBorderColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
179
186
  const markerStyle = isDoneMarkerStyle(values.doneMarkerStyle) ? values.doneMarkerStyle : DEFAULT_SETTINGS.decorations.doneMarkerStyle;
@@ -41,16 +41,18 @@ type PromptTheme = {
41
41
 
42
42
  const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
43
43
 
44
- /** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary. */
44
+ /** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary, then an optional pre-styled model suffix. */
45
45
  export function buildCompletionMarkerContent(
46
46
  theme: PromptTheme,
47
47
  icon: string,
48
48
  word: string,
49
49
  elapsed: string,
50
50
  details: string[],
51
+ model = "",
51
52
  ): string {
52
53
  const tail = details.length ? ` (${details.join(" · ")})` : "";
53
- return `${icon}${theme.fg("dim", `${icon ? " " : ""}${word} for ${elapsed}${tail}`)}`;
54
+ const summary = theme.fg("dim", `${icon ? " " : ""}${word} for ${elapsed}${tail}`);
55
+ return `${icon}${summary}${model ? `${theme.fg("dim", " · ")}${model}` : ""}`;
54
56
  }
55
57
 
56
58
  /** Build one decorated completion-marker line, clipped to the available width. */
package/src/session.ts CHANGED
@@ -38,14 +38,14 @@ import {
38
38
  StreamingWordCounter,
39
39
  type ThinkingLevel,
40
40
  } from "./format.ts";
41
- import { isSiblingSetupEnabled } from "./flags.ts";
42
41
  import { showMenu } from "./menu.ts";
42
+ import { getResponseModelColorizer } from "./nvidia-green.ts";
43
43
  import { registerSetupCommand } from "./setup-command.ts";
44
44
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
45
- import { notifyMissingToppingsOnce } from "./toppings.ts";
45
+ import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
46
46
  import { PreviewRenderer } from "./preview.ts";
47
47
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
48
- import { modelsResemble, stripControlChars } from "./util.ts";
48
+ import { isPlainObject, modelsResemble, stripControlChars } from "./util.ts";
49
49
  import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
50
50
 
51
51
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
@@ -77,6 +77,7 @@ interface DoneEntryData {
77
77
  tokens?: number;
78
78
  midTurnInputs?: number;
79
79
  thinkingLevel?: ThinkingLevel;
80
+ model?: string;
80
81
  }
81
82
 
82
83
  interface SessionState {
@@ -148,6 +149,10 @@ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
148
149
  return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
149
150
  }
150
151
 
152
+ function responseModelOf(message: unknown): unknown {
153
+ return isPlainObject(message) ? message.responseModel : undefined;
154
+ }
155
+
151
156
  /** Owns mutable extension state and Pi lifecycle registrations. */
152
157
  export class SessionManager {
153
158
  #counter = new StreamingWordCounter();
@@ -175,7 +180,7 @@ export class SessionManager {
175
180
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
176
181
  if (this.usable(ctx)) {
177
182
  this.applyIndicator(ctx);
178
- if (isSiblingSetupEnabled()) notifyMissingToppingsOnce(this.#pi, ctx.ui);
183
+ announceMissingToppingsOnce(this.#pi);
179
184
  }
180
185
  };
181
186
 
@@ -232,7 +237,7 @@ export class SessionManager {
232
237
  if (this.usable(ctx) && event.message.role === "assistant") {
233
238
  this.#state.liveTokens = 0;
234
239
  this.#counter.reset();
235
- this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
240
+ this.updateResponseModel(responseModelOf(event.message));
236
241
  }
237
242
  };
238
243
 
@@ -247,7 +252,7 @@ export class SessionManager {
247
252
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
248
253
  }
249
254
  const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
250
- ?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
255
+ ?? responseModelOf(event.message);
251
256
  this.updateResponseModel(rawResponseModel);
252
257
  };
253
258
 
@@ -268,7 +273,7 @@ export class SessionManager {
268
273
  }
269
274
  this.#state.liveTokens = 0;
270
275
  this.#counter.reset();
271
- this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
276
+ this.updateResponseModel(responseModelOf(event.message));
272
277
  };
273
278
 
274
279
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
@@ -324,6 +329,7 @@ export class SessionManager {
324
329
  tokens: this.#state.confirmTokens,
325
330
  midTurnInputs: this.#state.midTurnInputs,
326
331
  thinkingLevel: ctx.thinkingLevel,
332
+ model: this.#state.responseModel || undefined,
327
333
  });
328
334
  }
329
335
  this.#currentCtx = null;
@@ -346,11 +352,16 @@ export class SessionManager {
346
352
  details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
347
353
  }
348
354
  const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
349
- const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
355
+ const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
356
+ const rawModel = typeof entry.data.model === "string" ? stripControlChars(entry.data.model).trim() : "";
357
+ const modelColored = features.doneMarkerModel && rawModel
358
+ ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
359
+ : "";
360
+ const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
361
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model);
350
362
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
351
363
  const borderColor = this.#settings.decorations.doneMarkerBorderColor;
352
364
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
353
- const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
354
365
  if (borderStyle === "none") return new Text(markerContent);
355
366
 
356
367
  let cachedWidth: number | undefined;
@@ -383,12 +394,13 @@ export class SessionManager {
383
394
  this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
384
395
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
385
396
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
397
+ this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
386
398
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
387
399
  this.#pi.registerCommand("topping-settings", {
388
400
  description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
389
401
  handler: async (_a, ctx) => this.showSettings(ctx),
390
402
  });
391
- if (isSiblingSetupEnabled()) registerSetupCommand(this.#pi);
403
+ registerSetupCommand(this.#pi);
392
404
  }
393
405
 
394
406
  private usable(ctx: ExtensionContext): boolean {
@@ -487,7 +499,7 @@ export class SessionManager {
487
499
  private startResponseModelFade(ctx: ExtensionContext, model: string, color: ThinkingLevelColor, dimmed: boolean): void {
488
500
  this.cancelResponseModelFade(ctx);
489
501
  const generation = this.#state.responseModelFadeGeneration;
490
- const colorizer = getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel);
502
+ const colorizer = getResponseModelColorizer(ctx.ui.theme, color, ctx.thinkingLevel, ctx.model?.provider);
491
503
  const render = (shade?: number): void => {
492
504
  const colored = shade === undefined
493
505
  ? colorizer(model)
@@ -581,7 +593,7 @@ export class SessionManager {
581
593
  ? getThinkingLevelColorizer(ctx.ui.theme, decorations.spinnerColor, ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
582
594
  : "";
583
595
  const responseModelColored = features.responseModel && state.responseModel
584
- ? getThinkingLevelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel)(state.responseModel)
596
+ ? getResponseModelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel, ctx.model?.provider)(state.responseModel)
585
597
  : "";
586
598
  const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
587
599
  if (isFullyDefaultAppearance(features, decorations)) {
package/src/settings.ts CHANGED
@@ -73,6 +73,8 @@ export interface DecoratorSettings {
73
73
  doneMarkerBorderStyle: DoneMarkerBorderStyle;
74
74
  doneMarkerBorderColor: DoneMarkerBorderColor;
75
75
  doneMarkerStyle: DoneMarkerStyle;
76
+ doneMarkerModelColor: ThinkingLevelColor;
77
+ doneMarkerModelDimmed: boolean;
76
78
  spinnerColor: SpinnerColor;
77
79
  spinnerColorEnabled: boolean;
78
80
  meterColor: ThinkingLevelColor;
@@ -99,14 +101,15 @@ export interface DecoratorSettings {
99
101
  randomizeDoneMarker: boolean;
100
102
  doneMarkerTokens: boolean;
101
103
  doneMarkerInputs: boolean;
104
+ doneMarkerModel: boolean;
102
105
  };
103
106
  loaderOrder: LoaderElement[];
104
107
  wordPacks: Record<string, boolean>;
105
108
  }
106
109
 
107
110
  export const DEFAULT_SETTINGS: DecoratorSettings = {
108
- 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", spinnerColor: "thinking-level", 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 },
109
- features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
111
+ 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, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
112
+ features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true },
110
113
  loaderOrder: [...DEFAULT_LOADER_ORDER],
111
114
  wordPacks: {},
112
115
  };
@@ -154,7 +157,7 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
154
157
  else if (key === "borderColor" && isPromptBorderColor(value)) valid = value;
155
158
  else if (key === "doneMarkerBorderColor" && value === "default") valid = "thinking-level";
156
159
  else if (key === "doneMarkerBorderColor" && isDoneMarkerBorderColor(value)) valid = value;
157
- else if ((key === "meterColor" || key === "tokenRateColor" || key === "responseModelColor") && isThinkingLevelColor(value)) valid = value;
160
+ else if ((key === "meterColor" || key === "tokenRateColor" || key === "responseModelColor" || key === "doneMarkerModelColor") && isThinkingLevelColor(value)) valid = value;
158
161
  else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
159
162
  else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
160
163
  else if (key === "doneMarkerStyle" && isDoneMarkerStyle(value)) valid = value;
@@ -221,6 +224,9 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
221
224
  { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
222
225
  { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
223
226
  { id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
227
+ { id: "doneMarkerModel", label: "Response model", section: "Completion Marker", group: "features", key: "doneMarkerModel" },
228
+ { id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
229
+ { id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" },
224
230
  { id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
225
231
  ];
226
232
 
@@ -247,6 +253,7 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
247
253
  case "meterColor":
248
254
  case "tokenRateColor":
249
255
  case "responseModelColor":
256
+ case "doneMarkerModelColor":
250
257
  if (isThinkingLevelColor(value)) decorations[key] = value;
251
258
  return;
252
259
  case "doneMarkerBorderColor":
package/src/toppings.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { getAgentDir, type ExtensionAPI, type ExtensionUIContext, type Theme } from "@earendil-works/pi-coding-agent";
2
- import { visibleWidth } from "@earendil-works/pi-tui";
1
+ import { getAgentDir, type CustomEntry, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { isSetupCheckDisabled } from "./setup-check.ts";
@@ -65,6 +65,12 @@ interface SettingsToppingState {
65
65
  extensionActive: boolean;
66
66
  }
67
67
 
68
+ export const SETUP_ENTRY_TYPE = "pi-topping-setup";
69
+
70
+ export interface MissingToppingsEntryData {
71
+ readonly toppings: ReadonlyArray<Pick<Topping, "pkg" | "provides">>;
72
+ }
73
+
68
74
  let setupNoticeHandled = false;
69
75
 
70
76
  function stringEntries(value: unknown): string[] {
@@ -153,52 +159,60 @@ export function findMissingToppings(pi: ExtensionAPI): ToppingStatus[] {
153
159
  return detectToppings(pi).filter((status) => !status.active);
154
160
  }
155
161
 
156
- interface BannerLine {
157
- readonly plain: string;
158
- readonly styled: string;
159
- }
160
-
161
- function renderMissingBanner(missing: readonly ToppingStatus[], theme: Pick<Theme, "fg">): string {
162
+ function renderMissingToppings(
163
+ toppings: MissingToppingsEntryData["toppings"],
164
+ theme: Pick<Theme, "fg">,
165
+ width: number,
166
+ ): string[] {
167
+ const safeWidth = Math.max(0, width);
162
168
  const text = (value: string): string => theme.fg("text", value);
163
169
  const accent = (value: string): string => theme.fg("accent", value);
164
- const warning = (value: string): string => theme.fg("warning", value);
165
- const title: BannerLine = {
166
- plain: `pi-topping: ${missing.length} topping extensions missing`,
167
- styled: text(`pi-topping: ${missing.length} topping extensions missing`),
168
- };
169
- const body: BannerLine[] = [
170
- ...missing.map(({ topping }) => ({
171
- plain: `• ${topping.pkg} — ${topping.provides}`,
172
- styled: `${text("• ")}${accent(topping.pkg)}${text(` — ${topping.provides}`)}`,
173
- })),
174
- { plain: "", styled: "" },
175
- {
176
- plain: "Run /topping-setup to selectively install them",
177
- styled: `${text("Run ")}${accent("/topping-setup")}${text(" to selectively install them")}`,
170
+ const fit = (line: string): string => truncateToWidth(line, safeWidth, "");
171
+ const rule = theme.fg("warning", "─".repeat(safeWidth));
172
+ return [
173
+ rule,
174
+ fit(text(`pi-topping: ${toppings.length} topping extension${toppings.length === 1 ? "" : "s"} missing`)),
175
+ "",
176
+ ...toppings.map(({ pkg, provides }) => fit(`${text("• ")}${accent(pkg)}${text(` — ${provides}`)}`)),
177
+ "",
178
+ fit(`${text("Run ")}${accent("/topping-setup")}${text(" to selectively install them")}`),
179
+ fit(`${text("Run ")}${accent("/topping-setup disable-side-toppings-check")}${text(" to suppress this message")}`),
180
+ rule,
181
+ ];
182
+ }
183
+
184
+ export function renderMissingToppingsEntry(
185
+ entry: CustomEntry<MissingToppingsEntryData>,
186
+ theme: Theme,
187
+ ): Component | undefined {
188
+ if (!entry.data || !Array.isArray(entry.data.toppings)) return undefined;
189
+ let cachedWidth: number | undefined;
190
+ let cachedLines: string[] | undefined;
191
+ return {
192
+ render(width: number): string[] {
193
+ if (cachedWidth !== width) {
194
+ cachedWidth = width;
195
+ cachedLines = renderMissingToppings(entry.data!.toppings, theme, width);
196
+ }
197
+ return cachedLines!;
178
198
  },
179
- {
180
- plain: "Run /topping-setup disable-side-toppings-check to suppress this message",
181
- styled: `${text("Run ")}${accent("/topping-setup disable-side-toppings-check")}${text(" to suppress this message")}`,
199
+ invalidate(): void {
200
+ cachedWidth = undefined;
201
+ cachedLines = undefined;
182
202
  },
183
- ];
184
- const titleWidth = visibleWidth(title.plain);
185
- const width = Math.max(titleWidth + 6, ...body.map((line) => visibleWidth(line.plain) + 4));
186
- const blankRow = `${warning("│")}${" ".repeat(width - 2)}${warning("│")}`;
187
- return [
188
- `${warning("╭─")} ${title.styled} ${warning("─".repeat(width - titleWidth - 5))}${warning("╮")}`,
189
- blankRow,
190
- ...body.map((line) => `${warning("│")} ${line.styled}${" ".repeat(width - visibleWidth(line.plain) - 4)} ${warning("│")}`),
191
- blankRow,
192
- `${warning("╰")}${warning("─".repeat(width - 2))}${warning("╯")}`,
193
- ].join("\n");
203
+ };
194
204
  }
195
205
 
196
- export function notifyMissingToppingsOnce(pi: ExtensionAPI, ui: Pick<ExtensionUIContext, "notify" | "theme">): void {
206
+ export function announceMissingToppingsOnce(pi: ExtensionAPI): void {
197
207
  if (setupNoticeHandled) return;
198
208
  setupNoticeHandled = true;
199
209
  if (isSetupCheckDisabled()) return;
200
210
  const missing = findMissingToppings(pi);
201
- if (missing.length > 0) ui.notify(`\n${renderMissingBanner(missing, ui.theme)}`, "warning");
211
+ if (missing.length > 0) {
212
+ pi.appendEntry<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, {
213
+ toppings: missing.map(({ topping }) => ({ pkg: topping.pkg, provides: topping.provides })),
214
+ });
215
+ }
202
216
  }
203
217
 
204
218
  /** Test-only reset for the once-per-process session-start notice. */
package/src/flags.ts DELETED
@@ -1,19 +0,0 @@
1
- /**
2
- * Feature gates for unreleased functionality.
3
- *
4
- * Sibling-topping detection and the `/topping-setup` command ship dark: the
5
- * default below stays `false` until the flow is ready for the public. Setting
6
- * `PI_TOPPING_SIBLING_SETUP` to any value other than `0`, `false`, `off`, or
7
- * `no` (case-insensitive) opts a session in without touching code.
8
- */
9
-
10
- const SIBLING_SETUP_DEFAULT = false;
11
-
12
- const FALSEY_FLAG_VALUES = new Set(["0", "false", "off", "no"]);
13
-
14
- /** Whether the sibling-topping setup flow is registered and advertised. */
15
- export function isSiblingSetupEnabled(): boolean {
16
- const value = process.env.PI_TOPPING_SIBLING_SETUP?.trim().toLowerCase();
17
- if (value === undefined || value === "") return SIBLING_SETUP_DEFAULT;
18
- return !FALSEY_FLAG_VALUES.has(value);
19
- }