@underactive/pi-topping 0.5.1 → 0.6.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/src/session.ts CHANGED
@@ -33,11 +33,15 @@ import {
33
33
  TOKEN_RATE_PLACEHOLDER,
34
34
  StreamingWordCounter,
35
35
  } from "./format.ts";
36
+ import { isSiblingSetupEnabled } from "./flags.ts";
36
37
  import { showMenu } from "./menu.ts";
38
+ import { registerSetupCommand } from "./setup-command.ts";
37
39
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type SettingColor } from "./settings.ts";
40
+ import { notifyMissingToppingsOnce } from "./toppings.ts";
38
41
  import { PreviewRenderer } from "./preview.ts";
39
- import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, stripControlChars, type PromptBoxDetails } from "./prompt-decorator.ts";
40
- import { pickRandomWord, pickRawWord } from "./words.ts";
42
+ import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
43
+ import { stripControlChars } from "./util.ts";
44
+ import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
41
45
 
42
46
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
43
47
  type MessageUpdateEvent = Extract<ExtensionEvent, { type: "message_update" }>;
@@ -58,7 +62,7 @@ interface DoneEntryData {
58
62
 
59
63
  interface SessionState {
60
64
  startTime: number;
61
- currentWord: string;
65
+ workingText: WorkingTextSelection;
62
66
  confirmTokens: number;
63
67
  liveTokens: number;
64
68
  shimmerOrigin: number;
@@ -90,7 +94,7 @@ function resetTokenRateState(state: SessionState): void {
90
94
  function makeFreshState(): SessionState {
91
95
  return {
92
96
  startTime: 0,
93
- currentWord: "",
97
+ workingText: { text: "", pastTense: "Worked" },
94
98
  confirmTokens: 0,
95
99
  liveTokens: 0,
96
100
  shimmerOrigin: 0,
@@ -112,6 +116,8 @@ export class SessionManager {
112
116
  #counter = new StreamingWordCounter();
113
117
  #state = makeFreshState();
114
118
  #settings = loadSettings();
119
+ #userPacks: WordPack[] = [];
120
+ #bundledPacks = loadBundledWordPacks();
115
121
  #currentCtx: ExtensionContext | null = null;
116
122
  readonly #pi: ExtensionAPI;
117
123
 
@@ -124,9 +130,13 @@ export class SessionManager {
124
130
  this.#counter.reset();
125
131
  this.#state = makeFreshState();
126
132
  this.#settings = loadSettings();
133
+ this.#userPacks = loadUserWordPacks();
127
134
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
128
135
  this.#currentCtx = ctx;
129
- if (this.usable(ctx)) this.applyIndicator(ctx);
136
+ if (this.usable(ctx)) {
137
+ this.applyIndicator(ctx);
138
+ if (isSiblingSetupEnabled()) notifyMissingToppingsOnce(this.#pi, ctx.ui);
139
+ }
130
140
  };
131
141
 
132
142
  #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
@@ -209,7 +219,7 @@ export class SessionManager {
209
219
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
210
220
  this.#currentCtx = ctx;
211
221
  if (this.usable(ctx)) {
212
- this.#state.currentWord = pickRandomWord();
222
+ this.#state.workingText = this.pickWorkingWord();
213
223
  this.#state.shimmerOrigin = Date.now();
214
224
  this.tick();
215
225
  }
@@ -232,7 +242,7 @@ export class SessionManager {
232
242
  this.#state.lastMessage = NOT_SENT;
233
243
  if (this.#settings.features.doneMarker && hadPrompt) {
234
244
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
235
- word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
245
+ word: this.#settings.features.randomizeDoneMarker ? this.#state.workingText.pastTense : "Worked",
236
246
  elapsedMs,
237
247
  tokens: this.#state.confirmTokens,
238
248
  midTurnInputs: this.#state.midTurnInputs,
@@ -259,6 +269,7 @@ export class SessionManager {
259
269
  const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
260
270
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
261
271
  const borderColor = this.#settings.decorations.doneMarkerBorderColor;
272
+ const markerStyle = this.#settings.decorations.doneMarkerStyle;
262
273
  if (borderStyle === "none") return new Text(markerContent);
263
274
 
264
275
  let cachedWidth: number | undefined;
@@ -267,7 +278,7 @@ export class SessionManager {
267
278
  render(width: number): string[] {
268
279
  if (cachedWidth !== width) {
269
280
  cachedWidth = width;
270
- cachedLines = ["", ` ${buildCompletionMarkerLine(markerContent, width - 2, theme, borderStyle, borderColor)}`, ""];
281
+ cachedLines = ["", buildCompletionMarkerLine(markerContent, width, theme, borderStyle, borderColor, markerStyle), ""];
271
282
  }
272
283
  return cachedLines!;
273
284
  },
@@ -294,6 +305,7 @@ export class SessionManager {
294
305
  description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
295
306
  handler: async (_a, ctx) => this.showSettings(ctx),
296
307
  });
308
+ if (isSiblingSetupEnabled()) registerSetupCommand(this.#pi);
297
309
  }
298
310
 
299
311
  private usable(ctx: ExtensionContext): boolean {
@@ -367,11 +379,15 @@ export class SessionManager {
367
379
  if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
368
380
  }
369
381
 
382
+ private pickWorkingWord(): WorkingTextSelection {
383
+ return pickWorkingTextSelection(this.#settings.wordPacks, [...this.#bundledPacks, ...this.#userPacks]);
384
+ }
385
+
370
386
  private resetTurn(now: number): void {
371
387
  const state = this.#state;
372
388
  state.startTime = now;
373
389
  state.shimmerOrigin = now;
374
- state.currentWord = pickRandomWord();
390
+ state.workingText = this.pickWorkingWord();
375
391
  state.confirmTokens = 0;
376
392
  state.liveTokens = 0;
377
393
  state.activityMeter.reset();
@@ -413,7 +429,7 @@ export class SessionManager {
413
429
  return;
414
430
  }
415
431
 
416
- const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
432
+ const word = features.substituteDefaultMessage ? state.workingText.text : DEFAULT_WORKING_WORD;
417
433
  const styled = decorations.shimmer
418
434
  ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
419
435
  : ctx.ui.theme.fg("text", word);
@@ -476,11 +492,12 @@ export class SessionManager {
476
492
  return;
477
493
  }
478
494
 
495
+ this.#userPacks = loadUserWordPacks();
479
496
  const before = this.indicatorFingerprint();
480
- const preview = new PreviewRenderer(ctx);
497
+ const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
481
498
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
482
499
  title: "Pi Topping: Settings",
483
- sections: buildMenuSections(this.#settings),
500
+ sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
484
501
  hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
485
502
  preview: preview.render.bind(preview),
486
503
  });
package/src/settings.ts CHANGED
@@ -3,6 +3,8 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "
3
3
  import { dirname, join } from "node:path";
4
4
  import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
5
5
  import type { MenuSection } from "./menu.ts";
6
+ import { isWordPackEnabled, isWordPackId, type WordPack } from "./word-packs.ts";
7
+ import { isPlainObject } from "./util.ts";
6
8
 
7
9
  export const SETTING_COLOR_VALUES = ["accent", "border", "borderAccent", "success", "error", "warning"] as const;
8
10
  export type SettingColor = (typeof SETTING_COLOR_VALUES)[number];
@@ -11,6 +13,8 @@ export const BORDER_STYLE_VALUES = ["double", "single", "rounded", "heavy"] as c
11
13
  export type BorderStyle = (typeof BORDER_STYLE_VALUES)[number];
12
14
  export const DONE_MARKER_BORDER_STYLE_VALUES = [...BORDER_STYLE_VALUES, "none"] as const;
13
15
  export type DoneMarkerBorderStyle = (typeof DONE_MARKER_BORDER_STYLE_VALUES)[number];
16
+ export const DONE_MARKER_STYLE_VALUES = ["elite", "bookend"] as const;
17
+ export type DoneMarkerStyle = (typeof DONE_MARKER_STYLE_VALUES)[number];
14
18
 
15
19
  export function isBorderStyle(value: unknown): value is BorderStyle {
16
20
  return typeof value === "string" && BORDER_STYLE_VALUES.some(style => style === value);
@@ -20,6 +24,10 @@ export function isDoneMarkerBorderStyle(value: unknown): value is DoneMarkerBord
20
24
  return typeof value === "string" && DONE_MARKER_BORDER_STYLE_VALUES.some(style => style === value);
21
25
  }
22
26
 
27
+ export function isDoneMarkerStyle(value: unknown): value is DoneMarkerStyle {
28
+ return typeof value === "string" && DONE_MARKER_STYLE_VALUES.some(style => style === value);
29
+ }
30
+
23
31
  export function isSettingColor(value: unknown): value is SettingColor {
24
32
  return typeof value === "string" && SETTING_COLOR_VALUES.some(color => color === value);
25
33
  }
@@ -43,6 +51,7 @@ export interface DecoratorSettings {
43
51
  borderStyleEnabled: boolean;
44
52
  doneMarkerBorderStyle: DoneMarkerBorderStyle;
45
53
  doneMarkerBorderColor: SettingColor;
54
+ doneMarkerStyle: DoneMarkerStyle;
46
55
  spinnerColor: SettingColor;
47
56
  spinnerColorEnabled: boolean;
48
57
  meterColor: SettingColor;
@@ -68,12 +77,14 @@ export interface DecoratorSettings {
68
77
  doneMarkerInputs: boolean;
69
78
  };
70
79
  loaderOrder: LoaderElement[];
80
+ wordPacks: Record<string, boolean>;
71
81
  }
72
82
 
73
83
  export const DEFAULT_SETTINGS: DecoratorSettings = {
74
- decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
84
+ decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", doneMarkerStyle: "elite", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
75
85
  features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
76
86
  loaderOrder: [...DEFAULT_LOADER_ORDER],
87
+ wordPacks: {},
77
88
  };
78
89
 
79
90
  /** Menu key carrying the loader element order as a comma-joined list of element ids. */
@@ -104,7 +115,6 @@ export function parseLoaderOrder(value: unknown): LoaderElement[] {
104
115
  }
105
116
 
106
117
  export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
107
- function isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
108
118
  function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, parsed: unknown): T {
109
119
  const merged = { ...defaults };
110
120
  if (!isPlainObject(parsed)) return merged;
@@ -117,6 +127,7 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
117
127
  else if (key.endsWith("Color") && isSettingColor(value)) valid = value;
118
128
  else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
119
129
  else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
130
+ else if (key === "doneMarkerStyle" && isDoneMarkerStyle(value)) valid = value;
120
131
  if (valid !== undefined) (merged as Record<string, boolean | string>)[key] = valid;
121
132
  }
122
133
  return merged;
@@ -188,6 +199,7 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
188
199
  { id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: SETTING_COLOR_VALUES },
189
200
  { id: "tokenRateDimmed", label: "Token rate dimmed", section: "“Working” Loader", group: "decorations", key: "tokenRateDimmed" },
190
201
  { id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
202
+ { id: "doneMarkerStyle", label: "Marker style", section: "Completion Marker", group: "decorations", key: "doneMarkerStyle", cycleValues: DONE_MARKER_STYLE_VALUES },
191
203
  { id: "doneMarkerBorderStyle", label: "Border style", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderStyle", cycleValues: DONE_MARKER_BORDER_STYLE_VALUES },
192
204
  { id: "doneMarkerBorderColor", label: "Border color", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderColor", cycleValues: SETTING_COLOR_VALUES },
193
205
  { id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
@@ -225,6 +237,9 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
225
237
  case "doneMarkerBorderStyle":
226
238
  if (isDoneMarkerBorderStyle(stored)) decorations[key] = stored;
227
239
  return;
240
+ case "doneMarkerStyle":
241
+ if (isDoneMarkerStyle(stored)) decorations[key] = stored;
242
+ return;
228
243
  case "shimmerDirection":
229
244
  case "meterDirection":
230
245
  if (stored === "ltr" || stored === "rtl") decorations[key] = stored;
@@ -242,12 +257,14 @@ function buildSection(title: MenuSectionName, settings: DecoratorSettings): Menu
242
257
  return { title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) };
243
258
  }
244
259
 
245
- export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
260
+ export function buildMenuSections(settings: DecoratorSettings, bundledPacks: readonly WordPack[], userPacks: readonly WordPack[] = []): MenuSection[] {
261
+ const packs = [...bundledPacks, ...userPacks];
246
262
  return [
247
263
  buildSection("User Prompt", settings),
248
264
  buildSection("“Working” Loader", settings),
249
265
  { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
250
266
  buildSection("Completion Marker", settings),
267
+ { title: "Word Packs", items: packs.map((pack) => ({ id: `pack:${pack.id}`, label: pack.name, value: isWordPackEnabled(pack.id, settings.wordPacks) })) },
251
268
  buildSection("Options", settings),
252
269
  ];
253
270
  }
@@ -266,6 +283,11 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
266
283
  next.features[entry.key] = value;
267
284
  }
268
285
  }
286
+ for (const [id, value] of Object.entries(values)) {
287
+ if (!id.startsWith("pack:") || typeof value !== "boolean") continue;
288
+ const packId = id.slice("pack:".length);
289
+ if (isWordPackId(packId)) next.wordPacks[packId] = value;
290
+ }
269
291
  if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]);
270
292
  return next;
271
293
  }
@@ -280,7 +302,16 @@ export function loadSettings(): DecoratorSettings {
280
302
  try {
281
303
  const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
282
304
  if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
283
- const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder) };
305
+ const wordPacks: Record<string, boolean> = { ...DEFAULT_SETTINGS.wordPacks };
306
+ if (isPlainObject(parsed.wordPacks)) {
307
+ for (const [id, enabled] of Object.entries(parsed.wordPacks)) {
308
+ if (isWordPackId(id) && typeof enabled === "boolean") wordPacks[id] = enabled;
309
+ }
310
+ }
311
+ if (isPlainObject(parsed.features) && parsed.features.simCityWorkingText === true && !("simcity" in wordPacks)) {
312
+ wordPacks.simcity = true;
313
+ }
314
+ const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks };
284
315
  for (const entry of MENU_ENTRIES) {
285
316
  if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
286
317
  setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
@@ -290,15 +321,18 @@ export function loadSettings(): DecoratorSettings {
290
321
  } catch { return structuredClone(DEFAULT_SETTINGS); }
291
322
  }
292
323
 
293
- export function saveSettings(settings: DecoratorSettings): void {
294
- const path = settingsPath();
324
+ export function atomicWriteFile(path: string, contents: string): void {
295
325
  mkdirSync(dirname(path), { recursive: true });
296
326
  const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
297
327
  try {
298
- writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { flag: "wx" });
328
+ writeFileSync(tmpPath, contents, { flag: "wx" });
299
329
  renameSync(tmpPath, path);
300
330
  } catch (err) {
301
331
  try { unlinkSync(tmpPath); } catch { /* tmp file was never created */ }
302
332
  throw err;
303
333
  }
304
334
  }
335
+
336
+ export function saveSettings(settings: DecoratorSettings): void {
337
+ atomicWriteFile(settingsPath(), `${JSON.stringify(settings, null, 2)}\n`);
338
+ }
@@ -0,0 +1,20 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { readFileSync } from "node:fs";
4
+ import { atomicWriteFile } from "./settings.ts";
5
+ import { isPlainObject } from "./util.ts";
6
+
7
+ const setupCheckPath = (): string => join(getAgentDir(), "pi-topping", "setup-check.json");
8
+
9
+ export function isSetupCheckDisabled(): boolean {
10
+ try {
11
+ const parsed: unknown = JSON.parse(readFileSync(setupCheckPath(), "utf8"));
12
+ return isPlainObject(parsed) && parsed.disabled === true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function setSetupCheckDisabled(disabled: boolean): void {
19
+ atomicWriteFile(setupCheckPath(), `${JSON.stringify({ disabled })}\n`);
20
+ }
@@ -0,0 +1,136 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
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";
6
+ import { setSetupCheckDisabled } from "./setup-check.ts";
7
+ import { findMissingToppings, type ToppingStatus } from "./toppings.ts";
8
+
9
+ const INSTALL_TIMEOUT_MS = 120_000;
10
+ const STDERR_SNIPPET_CHARS = 300;
11
+
12
+ type ToppingInstaller = (spec: string, timeoutMs: number) => Promise<PiInstallResult>;
13
+
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);
18
+ }
19
+
20
+ function renderDescription(missing: readonly ToppingStatus[], activeItemId: string | undefined): string[] {
21
+ const active = missing.find(({ topping }) => topping.pkg === activeItemId);
22
+ 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)];
25
+ }
26
+
27
+ function errorMessage(error: unknown): string {
28
+ return error instanceof Error ? error.message : String(error);
29
+ }
30
+
31
+ function buildReport(succeeded: string[], failed: FailedInstall[]): string {
32
+ const lines: string[] = [];
33
+ if (succeeded.length > 0) lines.push(`✓ Installed: ${succeeded.join(", ")}`);
34
+ if (failed.length > 0) {
35
+ lines.push("✗ Failed:");
36
+ for (const failure of failed) lines.push(` ${failure.spec}: ${failure.error}`);
37
+ }
38
+ if (succeeded.length > 0) {
39
+ lines.push("");
40
+ lines.push("Restart Pi afterwards to activate them.");
41
+ }
42
+ return lines.join("\n");
43
+ }
44
+
45
+ async function installSelected(
46
+ ctx: ExtensionCommandContext,
47
+ selected: readonly ToppingStatus[],
48
+ install: ToppingInstaller,
49
+ ): Promise<{ succeeded: string[]; failed: FailedInstall[] }> {
50
+ const succeeded: string[] = [];
51
+ const failed: FailedInstall[] = [];
52
+ for (const { topping } of selected) {
53
+ const spec = `npm:${topping.pkg}`;
54
+ ctx.ui.notify(`Installing ${spec}…`, "info");
55
+ try {
56
+ const result = await install(spec, INSTALL_TIMEOUT_MS);
57
+ if (result.code === 0) {
58
+ succeeded.push(spec);
59
+ } else {
60
+ failed.push({ spec, error: stripControlChars((result.stderr || result.stdout || `exit ${result.code}`).trim()).slice(0, STDERR_SNIPPET_CHARS) });
61
+ }
62
+ } catch (error) {
63
+ failed.push({ spec, error: stripControlChars(errorMessage(error)).slice(0, STDERR_SNIPPET_CHARS) });
64
+ }
65
+ }
66
+ return { succeeded, failed };
67
+ }
68
+
69
+ export function registerSetupCommand(pi: ExtensionAPI, install: ToppingInstaller = spawnPiInstall): void {
70
+ pi.registerCommand("topping-setup", {
71
+ description: "Install missing Pi Topping sibling extensions or manage the missing-extension notice.",
72
+ handler: async (args, ctx) => {
73
+ const action = args.trim();
74
+ if (action === "disable-side-toppings-check" || action === "enable-side-toppings-check") {
75
+ try {
76
+ const disabled = action === "disable-side-toppings-check";
77
+ setSetupCheckDisabled(disabled);
78
+ ctx.ui.notify(
79
+ disabled
80
+ ? "Missing topping check disabled. Run `/topping-setup enable-side-toppings-check` to re-enable it."
81
+ : "Missing topping check enabled.",
82
+ "info",
83
+ );
84
+ } catch {
85
+ ctx.ui.notify(`Failed to ${action === "disable-side-toppings-check" ? "disable" : "enable"} the missing topping check.`, "error");
86
+ }
87
+ return;
88
+ }
89
+ if (action) {
90
+ ctx.ui.notify("Usage: /topping-setup [disable-side-toppings-check|enable-side-toppings-check]", "error");
91
+ return;
92
+ }
93
+
94
+ if (ctx.mode !== "tui") {
95
+ ctx.ui.notify("/topping-setup requires TUI mode", "error");
96
+ return;
97
+ }
98
+
99
+ const missing = findMissingToppings(pi);
100
+ if (missing.length === 0) {
101
+ ctx.ui.notify("All Pi Topping extensions are already active in this session.", "info");
102
+ return;
103
+ }
104
+
105
+ const result = await showMenu<Record<string, boolean>>(ctx, {
106
+ 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", "␣ toggle", "⏎ install", "esc cancel"],
118
+ previewTitle: "Description",
119
+ preview: (_values, _elapsedMs, activeItemId) => ({ lines: renderDescription(missing, activeItemId) }),
120
+ });
121
+ if (!result.applied) {
122
+ ctx.ui.notify("Pi Topping setup cancelled.", "info");
123
+ return;
124
+ }
125
+
126
+ const selected = selectedToppings(missing, result.values);
127
+ if (selected.length === 0) {
128
+ ctx.ui.notify("No Pi Topping extensions selected.", "info");
129
+ return;
130
+ }
131
+
132
+ const { succeeded, failed } = await installSelected(ctx, selected, install);
133
+ ctx.ui.notify(buildReport(succeeded, failed), failed.length > 0 ? "warning" : "info");
134
+ },
135
+ });
136
+ }
@@ -0,0 +1,158 @@
1
+ import { getAgentDir, type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { isSetupCheckDisabled } from "./setup-check.ts";
5
+ import { isPlainObject } from "./util.ts";
6
+
7
+ export interface Topping {
8
+ readonly pkg: string;
9
+ readonly matches: RegExp;
10
+ readonly dir: string;
11
+ readonly provides: string;
12
+ }
13
+
14
+ export interface ToppingStatus {
15
+ readonly topping: Topping;
16
+ readonly active: boolean;
17
+ readonly onDisk: boolean;
18
+ }
19
+
20
+ export const TOPPINGS: readonly Topping[] = [
21
+ {
22
+ pkg: "@underactive/pi-topping-statusline",
23
+ matches: /pi-topping-statusline/i,
24
+ dir: "pi-topping-statusline",
25
+ provides: "status line customization",
26
+ },
27
+ {
28
+ pkg: "@underactive/pi-topping-splash",
29
+ matches: /pi-topping-splash/i,
30
+ dir: "pi-topping-splash",
31
+ provides: "startup splash screen",
32
+ },
33
+ {
34
+ pkg: "@underactive/pi-topping-persona-audit",
35
+ matches: /pi-topping-persona-audit/i,
36
+ dir: "pi-topping-persona-audit",
37
+ provides: "persona audit tools",
38
+ },
39
+ {
40
+ pkg: "@underactive/pi-topping-web-tools",
41
+ matches: /pi-topping-web-tools/i,
42
+ dir: "pi-topping-web-tools",
43
+ provides: "web research and browser automation",
44
+ },
45
+ ];
46
+
47
+ interface PiSettings {
48
+ packages?: unknown;
49
+ extensions?: unknown;
50
+ }
51
+
52
+ interface SettingsToppingState {
53
+ packageInstalled: boolean;
54
+ extensionPresent: boolean;
55
+ extensionActive: boolean;
56
+ }
57
+
58
+ let setupNoticeHandled = false;
59
+
60
+ function stringEntries(value: unknown): string[] {
61
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
62
+ }
63
+
64
+ function readPiSettings(): PiSettings | undefined {
65
+ try {
66
+ const parsed: unknown = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8"));
67
+ return isPlainObject(parsed) ? parsed as PiSettings : undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ function extensionEntry(entry: string): { value: string; disabled: boolean } {
74
+ const value = entry.trim();
75
+ if (value.startsWith("+") || value.startsWith("-")) return { value: value.slice(1), disabled: value.startsWith("-") };
76
+ return { value, disabled: false };
77
+ }
78
+
79
+ function settingsState(settings: PiSettings | undefined, topping: Topping): SettingsToppingState {
80
+ let packageInstalled = false;
81
+ let extensionPresent = false;
82
+ let extensionActive = false;
83
+ for (const entry of stringEntries(settings?.packages)) {
84
+ if (topping.matches.test(entry)) packageInstalled = true;
85
+ }
86
+ for (const entry of stringEntries(settings?.extensions)) {
87
+ const normalized = extensionEntry(entry);
88
+ if (!topping.matches.test(normalized.value)) continue;
89
+ extensionPresent = true;
90
+ if (!normalized.disabled) extensionActive = true;
91
+ }
92
+ return { packageInstalled, extensionPresent, extensionActive };
93
+ }
94
+
95
+ function runtimeActiveToppings(pi: ExtensionAPI): Array<true | undefined> | undefined {
96
+ try {
97
+ const commands = pi.getCommands();
98
+ return TOPPINGS.map((topping) =>
99
+ commands.some((command) =>
100
+ command.source === "extension" && topping.matches.test(command.sourceInfo.path),
101
+ )
102
+ ? true
103
+ : undefined,
104
+ );
105
+ } catch {
106
+ // The extension runtime has not yet been bound during initial load.
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ export function detectToppings(pi: ExtensionAPI): ToppingStatus[] {
112
+ const runtime = runtimeActiveToppings(pi);
113
+ const settings = readPiSettings();
114
+ const extensionsDir = join(getAgentDir(), "extensions");
115
+ return TOPPINGS.map((topping, index) => {
116
+ const configured = settingsState(settings, topping);
117
+ const checkoutPresent = existsSync(join(extensionsDir, topping.dir));
118
+ const onDisk = configured.packageInstalled || configured.extensionPresent || checkoutPresent;
119
+ const fallbackActive = configured.packageInstalled || configured.extensionActive || checkoutPresent;
120
+ return { topping, active: runtime?.[index] ?? fallbackActive, onDisk };
121
+ });
122
+ }
123
+
124
+ export function findMissingToppings(pi: ExtensionAPI): ToppingStatus[] {
125
+ return detectToppings(pi).filter((status) => !status.active);
126
+ }
127
+
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",
135
+ ];
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
+ }
146
+
147
+ export function notifyMissingToppingsOnce(pi: ExtensionAPI, ui: Pick<ExtensionUIContext, "notify">): void {
148
+ if (setupNoticeHandled) return;
149
+ setupNoticeHandled = true;
150
+ if (isSetupCheckDisabled()) return;
151
+ const missing = findMissingToppings(pi);
152
+ if (missing.length > 0) ui.notify(`\n${renderMissingBanner(missing)}`, "warning");
153
+ }
154
+
155
+ /** Test-only reset for the once-per-process session-start notice. */
156
+ export function __resetSetupNotice(): void {
157
+ setupNoticeHandled = false;
158
+ }
package/src/util.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** True for object records, excluding arrays and null. */
2
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+
6
+ /** Strip control and formatting characters from untrusted single-line text. */
7
+ export function stripControlChars(text: string): string {
8
+ return text.replace(/[\p{Cc}\p{Cf}]/gu, "");
9
+ }