@underactive/pi-topping 0.7.0 → 0.7.1

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
@@ -112,14 +112,15 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
112
112
  "packs": [{
113
113
  "id": "cooking",
114
114
  "name": "Cooking",
115
+ "description": "Kitchen operations.",
115
116
  "words": [{ "present_tense": "Sautéing onions", "past_tense": "Sautéed" }]
116
117
  }]
117
118
  }
118
119
  ```
119
120
 
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.
121
+ 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
122
 
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.
123
+ 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
124
 
124
125
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
125
126
  left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
@@ -149,7 +150,7 @@ npm test
149
150
  npm run typecheck
150
151
  ```
151
152
 
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.
153
+ 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
154
 
154
155
  ## Limitations
155
156
 
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.1",
4
4
  "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
package/src/format.ts CHANGED
@@ -91,7 +91,7 @@ export function dimAttribute(text: string): string {
91
91
  return `\x1b[2m${text}\x1b[22m`;
92
92
  }
93
93
 
94
- /** Number of discrete, eased warning-to-dim shades used for the token-rate fade. */
94
+ /** Number of discrete, eased shades used by the token-rate and response-model fades. */
95
95
  export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
96
96
 
97
97
  /** Milliseconds a settled response model remains fully visible before fading. */
@@ -100,7 +100,7 @@ export const RESPONSE_MODEL_HOLD_MS = 3_000;
100
100
  /** Milliseconds over which a settled response model fades through the theme shades. */
101
101
  export const RESPONSE_MODEL_FADE_MS = 500;
102
102
 
103
- /** Render one token-rate fade shade by blending the selected color toward the active theme's dim color. */
103
+ /** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
104
104
  export function fadeThemeColorString(
105
105
  text: string,
106
106
  shade: number,
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`. */
@@ -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
+ }
@@ -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;
package/src/settings.ts CHANGED
@@ -318,6 +318,10 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
318
318
  * to defaults on any read/parse error. When a cycle setting's enabled flag is
319
319
  * off, its stored value resets to the menu's disabled default so stale choices
320
320
  * don't resurface on re-enable.
321
+ * Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and
322
+ * `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker
323
+ * border color is reset. A legacy `features.simCityWorkingText: true` enables the
324
+ * `simcity` pack when no explicit preference is stored.
321
325
  */
322
326
  export function loadSettings(): DecoratorSettings {
323
327
  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 ExtensionAPI, type ExtensionUIContext, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { visibleWidth } 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,6 +60,7 @@ 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
  }
@@ -61,6 +71,22 @@ function stringEntries(value: unknown): string[] {
61
71
  return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
62
72
  }
63
73
 
74
+ function sourceEntries(value: unknown): string[] {
75
+ if (!Array.isArray(value)) return [];
76
+ return value.flatMap((entry) => {
77
+ if (typeof entry === "string") return [entry];
78
+ return isPlainObject(entry) && typeof entry.source === "string" ? [entry.source] : [];
79
+ });
80
+ }
81
+
82
+ function npmPackageName(source: string): string | undefined {
83
+ const value = source.trim();
84
+ if (!value.startsWith("npm:")) return undefined;
85
+ const spec = value.slice("npm:".length);
86
+ const versionIndex = spec.lastIndexOf("@");
87
+ return versionIndex > 0 ? spec.slice(0, versionIndex) : spec;
88
+ }
89
+
64
90
  function readPiSettings(): PiSettings | undefined {
65
91
  try {
66
92
  const parsed: unknown = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8"));
@@ -78,10 +104,12 @@ function extensionEntry(entry: string): { value: string; disabled: boolean } {
78
104
 
79
105
  function settingsState(settings: PiSettings | undefined, topping: Topping): SettingsToppingState {
80
106
  let packageInstalled = false;
107
+ let packageSource: string | undefined;
81
108
  let extensionPresent = false;
82
109
  let extensionActive = false;
83
- for (const entry of stringEntries(settings?.packages)) {
110
+ for (const entry of sourceEntries(settings?.packages)) {
84
111
  if (topping.matches.test(entry)) packageInstalled = true;
112
+ if (npmPackageName(entry) === topping.pkg) packageSource = entry;
85
113
  }
86
114
  for (const entry of stringEntries(settings?.extensions)) {
87
115
  const normalized = extensionEntry(entry);
@@ -89,7 +117,7 @@ function settingsState(settings: PiSettings | undefined, topping: Topping): Sett
89
117
  extensionPresent = true;
90
118
  if (!normalized.disabled) extensionActive = true;
91
119
  }
92
- return { packageInstalled, extensionPresent, extensionActive };
120
+ return { packageInstalled, packageSource, extensionPresent, extensionActive };
93
121
  }
94
122
 
95
123
  function runtimeActiveToppings(pi: ExtensionAPI): Array<true | undefined> | undefined {
@@ -117,7 +145,7 @@ export function detectToppings(pi: ExtensionAPI): ToppingStatus[] {
117
145
  const checkoutPresent = existsSync(join(extensionsDir, topping.dir));
118
146
  const onDisk = configured.packageInstalled || configured.extensionPresent || checkoutPresent;
119
147
  const fallbackActive = configured.packageInstalled || configured.extensionActive || checkoutPresent;
120
- return { topping, active: runtime?.[index] ?? fallbackActive, onDisk };
148
+ return { topping, active: runtime?.[index] ?? fallbackActive, onDisk, packageSource: configured.packageSource };
121
149
  });
122
150
  }
123
151
 
@@ -125,31 +153,52 @@ export function findMissingToppings(pi: ExtensionAPI): ToppingStatus[] {
125
153
  return detectToppings(pi).filter((status) => !status.active);
126
154
  }
127
155
 
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}`),
132
- "",
133
- "Run `/topping-setup` to selectively install them",
134
- "Run `/topping-setup disable-side-toppings-check` to suppress this message",
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
+ const text = (value: string): string => theme.fg("text", value);
163
+ 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")}`,
178
+ },
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")}`,
182
+ },
135
183
  ];
136
- const width = Math.max(title.length + 6, ...body.map((line) => line.length + 4));
137
- const blankRow = `│${" ".repeat(width - 2)}│`;
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("│")}`;
138
187
  return [
139
- `╭─ ${title} ${"─".repeat(width - title.length - 5)}╮`,
188
+ `${warning("╭─")} ${title.styled} ${warning("─".repeat(width - titleWidth - 5))}${warning("╮")}`,
140
189
  blankRow,
141
- ...body.map((line) => `│ ${line.padEnd(width - 4)} │`),
190
+ ...body.map((line) => `${warning("│")} ${line.styled}${" ".repeat(width - visibleWidth(line.plain) - 4)} ${warning("│")}`),
142
191
  blankRow,
143
- `╰${"─".repeat(width - 2)}╯`,
192
+ `${warning("╰")}${warning("─".repeat(width - 2))}${warning("╯")}`,
144
193
  ].join("\n");
145
194
  }
146
195
 
147
- export function notifyMissingToppingsOnce(pi: ExtensionAPI, ui: Pick<ExtensionUIContext, "notify">): void {
196
+ export function notifyMissingToppingsOnce(pi: ExtensionAPI, ui: Pick<ExtensionUIContext, "notify" | "theme">): void {
148
197
  if (setupNoticeHandled) return;
149
198
  setupNoticeHandled = true;
150
199
  if (isSetupCheckDisabled()) return;
151
200
  const missing = findMissingToppings(pi);
152
- if (missing.length > 0) ui.notify(`\n${renderMissingBanner(missing)}`, "warning");
201
+ if (missing.length > 0) ui.notify(`\n${renderMissingBanner(missing, ui.theme)}`, "warning");
153
202
  }
154
203
 
155
204
  /** Test-only reset for the once-per-process session-start notice. */