@underactive/pi-topping 0.6.5 → 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.
@@ -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. */
package/src/word-packs.ts CHANGED
@@ -112,7 +112,7 @@ export function selectWorkingTextSelection(enabled: Record<string, boolean>, pac
112
112
  const total = WORDS.length + enabledWords.length;
113
113
  const idx = Math.min(total - 1, Math.floor(fraction * total));
114
114
  const entry = idx < WORDS.length ? WORDS[idx] : enabledWords[idx - WORDS.length];
115
- return { text: `${entry.present_tense}…`, pastTense: entry.past_tense };
115
+ return { text: entry.present_tense, pastTense: entry.past_tense };
116
116
  }
117
117
 
118
118
  export function pickWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[]): WorkingTextSelection {
package/src/words.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Activity word list for the randomized "Working..." indicator.
2
+ * Activity word list for the randomized "Working" indicator.
3
3
  *
4
4
  * A random word is picked on prompt and on tool execution start
5
- * (see index.ts).
5
+ * (see SessionManager in src/session.ts).
6
6
  */
7
7
 
8
8
  export interface WordEntry {
@@ -10,14 +10,6 @@ export interface WordEntry {
10
10
  past_tense: string;
11
11
  }
12
12
 
13
- export function pickRawWord(): WordEntry {
14
- return WORDS[Math.floor(Math.random() * WORDS.length)]!;
15
- }
16
-
17
- export function pickRandomWord(): string {
18
- return `${pickRawWord().present_tense}…`;
19
- }
20
-
21
13
  export const WORDS: WordEntry[] = [
22
14
  { present_tense: "Accomplishing", past_tense: "Accomplished" },
23
15
  { present_tense: "Actioning", past_tense: "Actioned" },