@underactive/pi-topping 0.7.0 → 0.7.2

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
+ The sibling-topping setup flow currently ships behind the `PI_TOPPING_SIBLING_SETUP=1` environment flag. When enabled, 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
@@ -112,14 +119,15 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
112
119
  "packs": [{
113
120
  "id": "cooking",
114
121
  "name": "Cooking",
122
+ "description": "Kitchen operations.",
115
123
  "words": [{ "present_tense": "Sautéing onions", "past_tense": "Sautéed" }]
116
124
  }]
117
125
  }
118
126
  ```
119
127
 
120
- 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.
128
+ Pack IDs must start with a lowercase letter, contain only lowercase letters, digits, and `-`, and be at most 64 characters. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. An optional `description` appears in the settings preview when the pack row is selected, and an optional `attribution` records the source. 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.
121
129
 
122
- The `default` animated spinner and completion marker border colors defer to Pi's thinking-level color. The `thinking-level` prompt border color follows the level captured when the prompt is submitted. The `thinkingLevel` color option for the token activity monitor, token rate, and response model follows Pi's active thinking-level color.
130
+ Every color setting offers `thinking-level` (shown as `thinkingLevel` in `/topping-settings`); it is the default for the animated spinner, the prompt border, and the completion marker border. The spinner, token activity monitor, token rate, and response model follow Pi's current thinking level. The prompt border uses the level captured when the prompt was submitted, and the completion marker uses the level captured when the turn settled, so earlier entries keep their color after you change level. When hand-editing `settings.json`, write `thinking-level`; the legacy `default` spelling is only recognised for `spinnerColor` and `doneMarkerBorderColor`.
123
131
 
124
132
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
125
133
  left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
@@ -133,7 +141,7 @@ full brightness for 1.5 seconds, then fades through five theme-aware shades to t
133
141
  the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
134
142
  restarts the cycle.
135
143
 
136
- 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.
137
145
 
138
146
  ## Requirements
139
147
 
@@ -149,7 +157,7 @@ npm test
149
157
  npm run typecheck
150
158
  ```
151
159
 
152
- Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component, and the settings menu wiring end to end.
160
+ Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence and schema migrations, word-pack parsing and loading, the `menu.ts` component, the settings menu wiring end to end, and the flag-gated `/topping-setup` command's install and uninstall flows.
153
161
 
154
162
  ## Limitations
155
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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" },
@@ -91,7 +96,7 @@ export function dimAttribute(text: string): string {
91
96
  return `\x1b[2m${text}\x1b[22m`;
92
97
  }
93
98
 
94
- /** Number of discrete, eased warning-to-dim shades used for the token-rate fade. */
99
+ /** Number of discrete, eased shades used by the token-rate and response-model fades. */
95
100
  export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
96
101
 
97
102
  /** Milliseconds a settled response model remains fully visible before fading. */
@@ -100,7 +105,7 @@ export const RESPONSE_MODEL_HOLD_MS = 3_000;
100
105
  /** Milliseconds over which a settled response model fades through the theme shades. */
101
106
  export const RESPONSE_MODEL_FADE_MS = 500;
102
107
 
103
- /** Render one token-rate fade shade by blending the selected color toward the active theme's dim color. */
108
+ /** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
104
109
  export function fadeThemeColorString(
105
110
  text: string,
106
111
  shade: number,
@@ -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();
package/src/menu.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * contract and `ctx.ui.custom()` overlay API.
5
5
  *
6
6
  * Renders a titled box containing one or more sections of boolean toggle,
7
- * multi-value cycle, or drag-to-reorder items, plus an optional live-updating
7
+ * multi-value cycle, or keyboard-reorderable items, plus an optional live-updating
8
8
  * preview section driven by a caller-supplied render callback.
9
9
  *
10
10
  * Intended to be reused by any extension that needs a simple modal toggle
@@ -57,11 +57,12 @@ export interface MenuConfig {
57
57
  /**
58
58
  * Optional preview renderer, shown in its own "Preview" section above the
59
59
  * toggle sections. Called on every render with the menu's current (possibly
60
- * toggled but not-yet-applied) values and the number of milliseconds elapsed
61
- * since the menu opened. Lines may contain ANSI styling and are truncated/
62
- * padded to fit automatically.
60
+ * toggled but not-yet-applied) values, the milliseconds elapsed since the menu
61
+ * opened, the id of the item under the cursor (undefined for an empty menu),
62
+ * and the width available to each line. Lines may contain ANSI styling and
63
+ * are truncated/padded to fit automatically.
63
64
  *
64
- * Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
65
+ * Omitting `nextRefreshInMs` (or returning a value <= 0) makes the preview static.
65
66
  */
66
67
  preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult;
67
68
  /** Optional heading for the preview block. Defaults to `Preview`. */
@@ -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
+ }
@@ -9,17 +9,16 @@ export interface PiInstallResult {
9
9
  const SIGKILL_GRACE_MS = 5_000;
10
10
  const EXIT_TIMEOUT = 124;
11
11
 
12
- /** Run `pi install <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
13
- export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
12
+ function spawnPiPackageCommand(command: "install" | "remove", spec: string, timeoutMs: number): Promise<PiInstallResult> {
14
13
  if (!/^npm:@?[\w.-]+(\/[\w.-]+)?$/.test(spec)) {
15
14
  return Promise.resolve({ code: 1, stdout: "", stderr: `invalid spec: ${spec}` });
16
15
  }
17
16
  return new Promise((resolve) => {
18
17
  const isWindows = process.platform === "win32";
19
- const [command, args, options] = isWindows
20
- ? (["cmd.exe", ["/c", "pi", "install", spec], { windowsHide: true }] as const)
21
- : (["pi", ["install", spec], {}] as const);
22
- const processHandle = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
18
+ const [executable, args, options] = isWindows
19
+ ? (["cmd.exe", ["/c", "pi", command, spec], { windowsHide: true }] as const)
20
+ : (["pi", [command, spec], {}] as const);
21
+ const processHandle = spawn(executable, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
23
22
  let stdout = "";
24
23
  let stderr = "";
25
24
  let settled = false;
@@ -61,3 +60,13 @@ export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInsta
61
60
  });
62
61
  });
63
62
  }
63
+
64
+ /** Run `pi install <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
65
+ export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
66
+ return spawnPiPackageCommand("install", spec, timeoutMs);
67
+ }
68
+
69
+ /** Run `pi remove <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
70
+ export function spawnPiRemove(spec: string, timeoutMs: number): Promise<PiInstallResult> {
71
+ return spawnPiPackageCommand("remove", spec, timeoutMs);
72
+ }
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;
@@ -15,7 +15,10 @@ const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; b
15
15
 
16
16
  /**
17
17
  * False visibility options hide each corresponding part; present provider and
18
- * model values join with `/` in the bottom border.
18
+ * model values join with `/` in the bottom border. `thinkingLevel` is the level
19
+ * captured at submission and is only used when `borderColor` is `thinking-level`.
20
+ * Unrecognised `borderStyle` / `borderColor` values fall back to `double` and the
21
+ * default border color.
19
22
  */
20
23
  export interface PromptBoxDetails {
21
24
  submittedAt?: number;
@@ -38,16 +41,18 @@ type PromptTheme = {
38
41
 
39
42
  const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
40
43
 
41
- /** 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. */
42
45
  export function buildCompletionMarkerContent(
43
46
  theme: PromptTheme,
44
47
  icon: string,
45
48
  word: string,
46
49
  elapsed: string,
47
50
  details: string[],
51
+ model = "",
48
52
  ): string {
49
53
  const tail = details.length ? ` (${details.join(" · ")})` : "";
50
- 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}` : ""}`;
51
56
  }
52
57
 
53
58
  /** Build one decorated completion-marker line, clipped to the available width. */
package/src/session.ts CHANGED
@@ -40,12 +40,13 @@ import {
40
40
  } from "./format.ts";
41
41
  import { isSiblingSetupEnabled } from "./flags.ts";
42
42
  import { showMenu } from "./menu.ts";
43
+ import { getResponseModelColorizer } from "./nvidia-green.ts";
43
44
  import { registerSetupCommand } from "./setup-command.ts";
44
45
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
45
- import { notifyMissingToppingsOnce } from "./toppings.ts";
46
+ import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
46
47
  import { PreviewRenderer } from "./preview.ts";
47
48
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
48
- import { modelsResemble, stripControlChars } from "./util.ts";
49
+ import { isPlainObject, modelsResemble, stripControlChars } from "./util.ts";
49
50
  import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
50
51
 
51
52
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
@@ -77,6 +78,7 @@ interface DoneEntryData {
77
78
  tokens?: number;
78
79
  midTurnInputs?: number;
79
80
  thinkingLevel?: ThinkingLevel;
81
+ model?: string;
80
82
  }
81
83
 
82
84
  interface SessionState {
@@ -148,6 +150,10 @@ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
148
150
  return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
149
151
  }
150
152
 
153
+ function responseModelOf(message: unknown): unknown {
154
+ return isPlainObject(message) ? message.responseModel : undefined;
155
+ }
156
+
151
157
  /** Owns mutable extension state and Pi lifecycle registrations. */
152
158
  export class SessionManager {
153
159
  #counter = new StreamingWordCounter();
@@ -175,7 +181,7 @@ export class SessionManager {
175
181
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
176
182
  if (this.usable(ctx)) {
177
183
  this.applyIndicator(ctx);
178
- if (isSiblingSetupEnabled()) notifyMissingToppingsOnce(this.#pi, ctx.ui);
184
+ if (isSiblingSetupEnabled()) announceMissingToppingsOnce(this.#pi);
179
185
  }
180
186
  };
181
187
 
@@ -232,7 +238,7 @@ export class SessionManager {
232
238
  if (this.usable(ctx) && event.message.role === "assistant") {
233
239
  this.#state.liveTokens = 0;
234
240
  this.#counter.reset();
235
- this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
241
+ this.updateResponseModel(responseModelOf(event.message));
236
242
  }
237
243
  };
238
244
 
@@ -247,7 +253,7 @@ export class SessionManager {
247
253
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
248
254
  }
249
255
  const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
250
- ?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
256
+ ?? responseModelOf(event.message);
251
257
  this.updateResponseModel(rawResponseModel);
252
258
  };
253
259
 
@@ -268,7 +274,7 @@ export class SessionManager {
268
274
  }
269
275
  this.#state.liveTokens = 0;
270
276
  this.#counter.reset();
271
- this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
277
+ this.updateResponseModel(responseModelOf(event.message));
272
278
  };
273
279
 
274
280
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
@@ -324,6 +330,7 @@ export class SessionManager {
324
330
  tokens: this.#state.confirmTokens,
325
331
  midTurnInputs: this.#state.midTurnInputs,
326
332
  thinkingLevel: ctx.thinkingLevel,
333
+ model: this.#state.responseModel || undefined,
327
334
  });
328
335
  }
329
336
  this.#currentCtx = null;
@@ -346,11 +353,16 @@ export class SessionManager {
346
353
  details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
347
354
  }
348
355
  const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
349
- const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
356
+ const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
357
+ const rawModel = typeof entry.data.model === "string" ? stripControlChars(entry.data.model).trim() : "";
358
+ const modelColored = features.doneMarkerModel && rawModel
359
+ ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
360
+ : "";
361
+ const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
362
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model);
350
363
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
351
364
  const borderColor = this.#settings.decorations.doneMarkerBorderColor;
352
365
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
353
- const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
354
366
  if (borderStyle === "none") return new Text(markerContent);
355
367
 
356
368
  let cachedWidth: number | undefined;
@@ -383,6 +395,7 @@ export class SessionManager {
383
395
  this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
384
396
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
385
397
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
398
+ if (isSiblingSetupEnabled()) this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
386
399
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
387
400
  this.#pi.registerCommand("topping-settings", {
388
401
  description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
@@ -487,7 +500,7 @@ export class SessionManager {
487
500
  private startResponseModelFade(ctx: ExtensionContext, model: string, color: ThinkingLevelColor, dimmed: boolean): void {
488
501
  this.cancelResponseModelFade(ctx);
489
502
  const generation = this.#state.responseModelFadeGeneration;
490
- const colorizer = getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel);
503
+ const colorizer = getResponseModelColorizer(ctx.ui.theme, color, ctx.thinkingLevel, ctx.model?.provider);
491
504
  const render = (shade?: number): void => {
492
505
  const colored = shade === undefined
493
506
  ? colorizer(model)
@@ -581,7 +594,7 @@ export class SessionManager {
581
594
  ? getThinkingLevelColorizer(ctx.ui.theme, decorations.spinnerColor, ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
582
595
  : "";
583
596
  const responseModelColored = features.responseModel && state.responseModel
584
- ? getThinkingLevelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel)(state.responseModel)
597
+ ? getResponseModelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel, ctx.model?.provider)(state.responseModel)
585
598
  : "";
586
599
  const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
587
600
  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":
@@ -318,6 +325,10 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
318
325
  * to defaults on any read/parse error. When a cycle setting's enabled flag is
319
326
  * off, its stored value resets to the menu's disabled default so stale choices
320
327
  * don't resurface on re-enable.
328
+ * Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and
329
+ * `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker
330
+ * border color is reset. A legacy `features.simCityWorkingText: true` enables the
331
+ * `simcity` pack when no explicit preference is stored.
321
332
  */
322
333
  export function loadSettings(): DecoratorSettings {
323
334
  try {
@@ -1,74 +1,99 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { hyperlink } from "@earendil-works/pi-tui";
3
- import { showMenu } from "./menu.ts";
4
- import { type PiInstallResult, spawnPiInstall } from "./pi-installer.ts";
5
- import { stripControlChars } from "./util.ts";
3
+ import { type MenuValue, showMenu } from "./menu.ts";
4
+ import { type PiInstallResult, spawnPiInstall, spawnPiRemove } from "./pi-installer.ts";
6
5
  import { setSetupCheckDisabled } from "./setup-check.ts";
7
- import { findMissingToppings, type ToppingStatus } from "./toppings.ts";
6
+ import { detectToppings, type ToppingStatus } from "./toppings.ts";
7
+ import { stripControlChars } from "./util.ts";
8
8
 
9
- const INSTALL_TIMEOUT_MS = 120_000;
9
+ const PI_COMMAND_TIMEOUT_MS = 120_000;
10
10
  const STDERR_SNIPPET_CHARS = 300;
11
11
 
12
- type ToppingInstaller = (spec: string, timeoutMs: number) => Promise<PiInstallResult>;
12
+ type PackageAction = "install" | "remove";
13
+ type PiPackageRunner = (spec: string, timeoutMs: number) => Promise<PiInstallResult>;
14
+ type FailedAction = { action: PackageAction; spec: string; error: string };
15
+ type SetupRow = { status: ToppingStatus; action: PackageAction };
13
16
 
14
- type FailedInstall = { spec: string; error: string };
15
-
16
- function selectedToppings(missing: readonly ToppingStatus[], values: Record<string, boolean>): ToppingStatus[] {
17
- return missing.filter(({ topping }) => values[topping.pkg] === true);
17
+ function selectedRows(
18
+ rows: ReadonlyMap<string, SetupRow>,
19
+ values: Record<string, boolean>,
20
+ action: PackageAction,
21
+ ): ToppingStatus[] {
22
+ return [...rows.entries()]
23
+ .filter(([id, row]) => row.action === action && values[id] === true)
24
+ .map(([, row]) => row.status);
18
25
  }
19
26
 
20
- function renderDescription(missing: readonly ToppingStatus[], activeItemId: string | undefined): string[] {
21
- const active = missing.find(({ topping }) => topping.pkg === activeItemId);
27
+ function renderDescription(
28
+ rows: ReadonlyMap<string, SetupRow>,
29
+ activeItemId: string | undefined,
30
+ values: Record<string, MenuValue>,
31
+ ): string[] {
32
+ if (activeItemId === undefined) return ["Select a topping to see what it does."];
33
+ const active = rows.get(activeItemId);
22
34
  if (!active) return ["Select a topping to see what it does."];
23
- const url = `https://pi.dev/packages/${active.topping.pkg}`;
24
- return [active.topping.provides, "", hyperlink(url, url)];
35
+ const url = `https://pi.dev/packages/${active.status.topping.pkg}`;
36
+ const action = values[activeItemId] === true
37
+ ? `Checked: will ${active.action === "install" ? "install" : "uninstall"}`
38
+ : "Unchecked: no change";
39
+ return [active.status.topping.provides, "", hyperlink(url, url), "", action];
25
40
  }
26
41
 
27
42
  function errorMessage(error: unknown): string {
28
43
  return error instanceof Error ? error.message : String(error);
29
44
  }
30
45
 
31
- function buildReport(succeeded: string[], failed: FailedInstall[]): string {
46
+ function snippet(text: string): string {
47
+ return stripControlChars(text.trim()).slice(0, STDERR_SNIPPET_CHARS);
48
+ }
49
+
50
+ function buildReport(installed: string[], removed: string[], failed: FailedAction[]): string {
32
51
  const lines: string[] = [];
33
- if (succeeded.length > 0) lines.push(`✓ Installed: ${succeeded.join(", ")}`);
52
+ if (installed.length > 0) lines.push(`✓ Installed: ${installed.join(", ")}`);
53
+ if (removed.length > 0) lines.push(`✓ Removed: ${removed.join(", ")}`);
34
54
  if (failed.length > 0) {
35
55
  lines.push("✗ Failed:");
36
- for (const failure of failed) lines.push(` ${failure.spec}: ${failure.error}`);
56
+ for (const failure of failed) lines.push(` ${failure.action} ${failure.spec}: ${failure.error}`);
37
57
  }
38
- if (succeeded.length > 0) {
58
+ if (installed.length > 0 || removed.length > 0) {
39
59
  lines.push("");
40
- lines.push("Restart Pi afterwards to activate them.");
60
+ lines.push("Restart Pi afterwards to apply the changes.");
41
61
  }
42
62
  return lines.join("\n");
43
63
  }
44
64
 
45
- async function installSelected(
65
+ async function runPackageCommands(
46
66
  ctx: ExtensionCommandContext,
47
67
  selected: readonly ToppingStatus[],
48
- install: ToppingInstaller,
49
- ): Promise<{ succeeded: string[]; failed: FailedInstall[] }> {
68
+ runner: PiPackageRunner,
69
+ action: PackageAction,
70
+ ): Promise<{ succeeded: string[]; failed: FailedAction[] }> {
50
71
  const succeeded: string[] = [];
51
- const failed: FailedInstall[] = [];
72
+ const failed: FailedAction[] = [];
52
73
  for (const { topping } of selected) {
53
74
  const spec = `npm:${topping.pkg}`;
54
- ctx.ui.notify(`Installing ${spec}…`, "info");
75
+ ctx.ui.notify(`${action === "install" ? "Installing" : "Removing"} ${spec}…`, "info");
55
76
  try {
56
- const result = await install(spec, INSTALL_TIMEOUT_MS);
77
+ const result = await runner(spec, PI_COMMAND_TIMEOUT_MS);
57
78
  if (result.code === 0) {
58
79
  succeeded.push(spec);
59
80
  } else {
60
- failed.push({ spec, error: stripControlChars((result.stderr || result.stdout || `exit ${result.code}`).trim()).slice(0, STDERR_SNIPPET_CHARS) });
81
+ failed.push({ action, spec, error: snippet(result.stderr || result.stdout || `exit ${result.code}`) });
61
82
  }
62
83
  } catch (error) {
63
- failed.push({ spec, error: stripControlChars(errorMessage(error)).slice(0, STDERR_SNIPPET_CHARS) });
84
+ failed.push({ action, spec, error: snippet(errorMessage(error)) });
64
85
  }
65
86
  }
66
87
  return { succeeded, failed };
67
88
  }
68
89
 
69
- export function registerSetupCommand(pi: ExtensionAPI, install: ToppingInstaller = spawnPiInstall): void {
90
+ export function registerSetupCommand(
91
+ pi: ExtensionAPI,
92
+ install: PiPackageRunner = spawnPiInstall,
93
+ remove: PiPackageRunner = spawnPiRemove,
94
+ ): void {
70
95
  pi.registerCommand("topping-setup", {
71
- description: "Install missing Pi Topping sibling extensions or manage the missing-extension notice.",
96
+ description: "Install or uninstall Pi Topping sibling extensions or manage the missing-extension notice.",
72
97
  handler: async (args, ctx) => {
73
98
  const action = args.trim();
74
99
  if (action === "disable-side-toppings-check" || action === "enable-side-toppings-check") {
@@ -96,41 +121,62 @@ export function registerSetupCommand(pi: ExtensionAPI, install: ToppingInstaller
96
121
  return;
97
122
  }
98
123
 
99
- const missing = findMissingToppings(pi);
100
- if (missing.length === 0) {
124
+ const statuses = detectToppings(pi);
125
+ const removable = statuses.filter((status) => status.packageSource !== undefined);
126
+ const missing = statuses.filter((status) => !status.active && status.packageSource === undefined);
127
+ if (missing.length === 0 && removable.length === 0) {
101
128
  ctx.ui.notify("All Pi Topping extensions are already active in this session.", "info");
102
129
  return;
103
130
  }
104
131
 
132
+ const rows = new Map<string, SetupRow>();
133
+ for (const status of missing) rows.set(`install:${status.topping.pkg}`, { status, action: "install" });
134
+ for (const status of removable) rows.set(`remove:${status.topping.pkg}`, { status, action: "remove" });
135
+ const sections = [];
136
+ if (missing.length > 0) {
137
+ sections.push({
138
+ title: "Missing Toppings",
139
+ items: missing.map(({ topping, onDisk }) => ({
140
+ id: `install:${topping.pkg}`,
141
+ label: `${topping.pkg}${onDisk ? " (on disk, not active)" : ""}`,
142
+ value: true,
143
+ })),
144
+ });
145
+ }
146
+ if (removable.length > 0) {
147
+ sections.push({
148
+ title: "Installed Toppings",
149
+ items: removable.map(({ topping, active }) => ({
150
+ id: `remove:${topping.pkg}`,
151
+ label: `${topping.pkg}${active ? " (active)" : " (installed, not active)"}`,
152
+ value: false,
153
+ })),
154
+ });
155
+ }
156
+
105
157
  const result = await showMenu<Record<string, boolean>>(ctx, {
106
158
  title: "Pi Topping: Setup",
107
- sections: [
108
- {
109
- title: "Missing Toppings",
110
- items: missing.map(({ topping, onDisk }) => ({
111
- id: topping.pkg,
112
- label: `${topping.pkg}${onDisk ? " (on disk, not active)" : ""}`,
113
- value: true,
114
- })),
115
- },
116
- ],
117
- hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ install", "esc cancel"],
159
+ sections,
160
+ hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ apply", "esc cancel"],
118
161
  previewTitle: "Description",
119
- preview: (_values, _elapsedMs, activeItemId) => ({ lines: renderDescription(missing, activeItemId) }),
162
+ preview: (values, _elapsedMs, activeItemId) => ({ lines: renderDescription(rows, activeItemId, values) }),
120
163
  });
121
164
  if (!result.applied) {
122
165
  ctx.ui.notify("Pi Topping setup cancelled.", "info");
123
166
  return;
124
167
  }
125
168
 
126
- const selected = selectedToppings(missing, result.values);
127
- if (selected.length === 0) {
128
- ctx.ui.notify("No Pi Topping extensions selected.", "info");
169
+ const removals = selectedRows(rows, result.values, "remove");
170
+ const installs = selectedRows(rows, result.values, "install");
171
+ if (removals.length === 0 && installs.length === 0) {
172
+ ctx.ui.notify("No Pi Topping changes selected.", "info");
129
173
  return;
130
174
  }
131
175
 
132
- const { succeeded, failed } = await installSelected(ctx, selected, install);
133
- ctx.ui.notify(buildReport(succeeded, failed), failed.length > 0 ? "warning" : "info");
176
+ const removalResult = await runPackageCommands(ctx, removals, remove, "remove");
177
+ const installResult = await runPackageCommands(ctx, installs, install, "install");
178
+ const failed = [...removalResult.failed, ...installResult.failed];
179
+ ctx.ui.notify(buildReport(installResult.succeeded, removalResult.succeeded, failed), failed.length > 0 ? "warning" : "info");
134
180
  },
135
181
  });
136
182
  }
package/src/toppings.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { getAgentDir, type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
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";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { isSetupCheckDisabled } from "./setup-check.ts";
@@ -15,6 +16,8 @@ export interface ToppingStatus {
15
16
  readonly topping: Topping;
16
17
  readonly active: boolean;
17
18
  readonly onDisk: boolean;
19
+ /** Exact npm source recorded in Pi's user settings for this topping. */
20
+ readonly packageSource: string | undefined;
18
21
  }
19
22
 
20
23
  export const TOPPINGS: readonly Topping[] = [
@@ -42,6 +45,12 @@ export const TOPPINGS: readonly Topping[] = [
42
45
  dir: "pi-topping-web-tools",
43
46
  provides: "web research and browser automation",
44
47
  },
48
+ {
49
+ pkg: "@underactive/pi-topping-moa-fusion",
50
+ matches: /pi-topping-moa-fusion/i,
51
+ dir: "pi-topping-moa-fusion",
52
+ provides: "Mixture-of-Agents Fusion plan mode",
53
+ },
45
54
  ];
46
55
 
47
56
  interface PiSettings {
@@ -51,16 +60,39 @@ interface PiSettings {
51
60
 
52
61
  interface SettingsToppingState {
53
62
  packageInstalled: boolean;
63
+ packageSource?: string;
54
64
  extensionPresent: boolean;
55
65
  extensionActive: boolean;
56
66
  }
57
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
+
58
74
  let setupNoticeHandled = false;
59
75
 
60
76
  function stringEntries(value: unknown): string[] {
61
77
  return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
62
78
  }
63
79
 
80
+ function sourceEntries(value: unknown): string[] {
81
+ if (!Array.isArray(value)) return [];
82
+ return value.flatMap((entry) => {
83
+ if (typeof entry === "string") return [entry];
84
+ return isPlainObject(entry) && typeof entry.source === "string" ? [entry.source] : [];
85
+ });
86
+ }
87
+
88
+ function npmPackageName(source: string): string | undefined {
89
+ const value = source.trim();
90
+ if (!value.startsWith("npm:")) return undefined;
91
+ const spec = value.slice("npm:".length);
92
+ const versionIndex = spec.lastIndexOf("@");
93
+ return versionIndex > 0 ? spec.slice(0, versionIndex) : spec;
94
+ }
95
+
64
96
  function readPiSettings(): PiSettings | undefined {
65
97
  try {
66
98
  const parsed: unknown = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8"));
@@ -78,10 +110,12 @@ function extensionEntry(entry: string): { value: string; disabled: boolean } {
78
110
 
79
111
  function settingsState(settings: PiSettings | undefined, topping: Topping): SettingsToppingState {
80
112
  let packageInstalled = false;
113
+ let packageSource: string | undefined;
81
114
  let extensionPresent = false;
82
115
  let extensionActive = false;
83
- for (const entry of stringEntries(settings?.packages)) {
116
+ for (const entry of sourceEntries(settings?.packages)) {
84
117
  if (topping.matches.test(entry)) packageInstalled = true;
118
+ if (npmPackageName(entry) === topping.pkg) packageSource = entry;
85
119
  }
86
120
  for (const entry of stringEntries(settings?.extensions)) {
87
121
  const normalized = extensionEntry(entry);
@@ -89,7 +123,7 @@ function settingsState(settings: PiSettings | undefined, topping: Topping): Sett
89
123
  extensionPresent = true;
90
124
  if (!normalized.disabled) extensionActive = true;
91
125
  }
92
- return { packageInstalled, extensionPresent, extensionActive };
126
+ return { packageInstalled, packageSource, extensionPresent, extensionActive };
93
127
  }
94
128
 
95
129
  function runtimeActiveToppings(pi: ExtensionAPI): Array<true | undefined> | undefined {
@@ -117,7 +151,7 @@ export function detectToppings(pi: ExtensionAPI): ToppingStatus[] {
117
151
  const checkoutPresent = existsSync(join(extensionsDir, topping.dir));
118
152
  const onDisk = configured.packageInstalled || configured.extensionPresent || checkoutPresent;
119
153
  const fallbackActive = configured.packageInstalled || configured.extensionActive || checkoutPresent;
120
- return { topping, active: runtime?.[index] ?? fallbackActive, onDisk };
154
+ return { topping, active: runtime?.[index] ?? fallbackActive, onDisk, packageSource: configured.packageSource };
121
155
  });
122
156
  }
123
157
 
@@ -125,31 +159,60 @@ export function findMissingToppings(pi: ExtensionAPI): ToppingStatus[] {
125
159
  return detectToppings(pi).filter((status) => !status.active);
126
160
  }
127
161
 
128
- function renderMissingBanner(missing: readonly ToppingStatus[]): string {
129
- const title = `pi-topping: ${missing.length} topping extensions missing`;
130
- const body = [
131
- ...missing.map(({ topping }) => `• ${topping.pkg} — ${topping.provides}`),
162
+ function renderMissingToppings(
163
+ toppings: MissingToppingsEntryData["toppings"],
164
+ theme: Pick<Theme, "fg">,
165
+ width: number,
166
+ ): string[] {
167
+ const safeWidth = Math.max(0, width);
168
+ const text = (value: string): string => theme.fg("text", value);
169
+ const accent = (value: string): string => theme.fg("accent", value);
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}`)}`)),
132
177
  "",
133
- "Run `/topping-setup` to selectively install them",
134
- "Run `/topping-setup disable-side-toppings-check` to suppress this message",
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,
135
181
  ];
136
- const width = Math.max(title.length + 6, ...body.map((line) => line.length + 4));
137
- const blankRow = `│${" ".repeat(width - 2)}│`;
138
- return [
139
- `╭─ ${title} ${"─".repeat(width - title.length - 5)}╮`,
140
- blankRow,
141
- ...body.map((line) => `│ ${line.padEnd(width - 4)} │`),
142
- blankRow,
143
- `╰${"─".repeat(width - 2)}╯`,
144
- ].join("\n");
145
182
  }
146
183
 
147
- export function notifyMissingToppingsOnce(pi: ExtensionAPI, ui: Pick<ExtensionUIContext, "notify">): void {
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!;
198
+ },
199
+ invalidate(): void {
200
+ cachedWidth = undefined;
201
+ cachedLines = undefined;
202
+ },
203
+ };
204
+ }
205
+
206
+ export function announceMissingToppingsOnce(pi: ExtensionAPI): void {
148
207
  if (setupNoticeHandled) return;
149
208
  setupNoticeHandled = true;
150
209
  if (isSetupCheckDisabled()) return;
151
210
  const missing = findMissingToppings(pi);
152
- if (missing.length > 0) ui.notify(`\n${renderMissingBanner(missing)}`, "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
+ }
153
216
  }
154
217
 
155
218
  /** Test-only reset for the once-per-process session-start notice. */