@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 +4 -3
- package/package.json +1 -1
- package/src/format.ts +2 -2
- package/src/menu.ts +6 -5
- package/src/pi-installer.ts +15 -6
- package/src/prompt-decorator.ts +4 -1
- package/src/settings.ts +4 -0
- package/src/setup-command.ts +94 -48
- package/src/toppings.ts +67 -18
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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`
|
|
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`. */
|
package/src/pi-installer.ts
CHANGED
|
@@ -9,17 +9,16 @@ export interface PiInstallResult {
|
|
|
9
9
|
const SIGKILL_GRACE_MS = 5_000;
|
|
10
10
|
const EXIT_TIMEOUT = 124;
|
|
11
11
|
|
|
12
|
-
|
|
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 [
|
|
20
|
-
? (["cmd.exe", ["/c", "pi",
|
|
21
|
-
: (["pi", [
|
|
22
|
-
const processHandle = spawn(
|
|
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/prompt-decorator.ts
CHANGED
|
@@ -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 {
|
package/src/setup-command.ts
CHANGED
|
@@ -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 {
|
|
6
|
+
import { detectToppings, type ToppingStatus } from "./toppings.ts";
|
|
7
|
+
import { stripControlChars } from "./util.ts";
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const PI_COMMAND_TIMEOUT_MS = 120_000;
|
|
10
10
|
const STDERR_SNIPPET_CHARS = 300;
|
|
11
11
|
|
|
12
|
-
type
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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(
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
58
|
+
if (installed.length > 0 || removed.length > 0) {
|
|
39
59
|
lines.push("");
|
|
40
|
-
lines.push("Restart Pi afterwards to
|
|
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
|
|
65
|
+
async function runPackageCommands(
|
|
46
66
|
ctx: ExtensionCommandContext,
|
|
47
67
|
selected: readonly ToppingStatus[],
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
runner: PiPackageRunner,
|
|
69
|
+
action: PackageAction,
|
|
70
|
+
): Promise<{ succeeded: string[]; failed: FailedAction[] }> {
|
|
50
71
|
const succeeded: string[] = [];
|
|
51
|
-
const failed:
|
|
72
|
+
const failed: FailedAction[] = [];
|
|
52
73
|
for (const { topping } of selected) {
|
|
53
74
|
const spec = `npm:${topping.pkg}`;
|
|
54
|
-
ctx.ui.notify(
|
|
75
|
+
ctx.ui.notify(`${action === "install" ? "Installing" : "Removing"} ${spec}…`, "info");
|
|
55
76
|
try {
|
|
56
|
-
const result = await
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
100
|
-
|
|
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: (
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
137
|
-
const
|
|
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
|
-
|
|
188
|
+
`${warning("╭─")} ${title.styled} ${warning("─".repeat(width - titleWidth - 5))}${warning("╮")}`,
|
|
140
189
|
blankRow,
|
|
141
|
-
...body.map((line) =>
|
|
190
|
+
...body.map((line) => `${warning("│")} ${line.styled}${" ".repeat(width - visibleWidth(line.plain) - 4)} ${warning("│")}`),
|
|
142
191
|
blankRow,
|
|
143
|
-
|
|
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. */
|