@underactive/pi-topping 0.5.0 → 0.6.0
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 +24 -10
- package/index.ts +0 -1
- package/package.json +3 -3
- package/src/flags.ts +19 -0
- package/src/menu.ts +28 -20
- package/src/pi-installer.ts +63 -0
- package/src/preview.ts +46 -13
- package/src/prompt-decorator.ts +9 -8
- package/src/session.ts +42 -17
- package/src/settings.ts +41 -7
- package/src/setup-check.ts +20 -0
- package/src/setup-command.ts +136 -0
- package/src/toppings.ts +158 -0
- package/src/util.ts +9 -0
- package/src/word-packs.ts +119 -0
- package/wordpacks/simcity.json +432 -0
- package/wordpacks/star-trek.json +283 -0
- package/wordpacks/star-wars.json +283 -0
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,
|
|
40
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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> => {
|
|
@@ -154,10 +164,7 @@ export class SessionManager {
|
|
|
154
164
|
borderStyle: this.#settings.decorations.borderStyle,
|
|
155
165
|
},
|
|
156
166
|
},
|
|
157
|
-
{
|
|
158
|
-
triggerTurn: true,
|
|
159
|
-
...(event.streamingBehavior ? { deliverAs: event.streamingBehavior } : {}),
|
|
160
|
-
},
|
|
167
|
+
{ triggerTurn: true },
|
|
161
168
|
);
|
|
162
169
|
return { action: "handled" };
|
|
163
170
|
}
|
|
@@ -212,7 +219,7 @@ export class SessionManager {
|
|
|
212
219
|
#onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
213
220
|
this.#currentCtx = ctx;
|
|
214
221
|
if (this.usable(ctx)) {
|
|
215
|
-
this.#state.
|
|
222
|
+
this.#state.workingText = this.pickWorkingWord();
|
|
216
223
|
this.#state.shimmerOrigin = Date.now();
|
|
217
224
|
this.tick();
|
|
218
225
|
}
|
|
@@ -235,7 +242,7 @@ export class SessionManager {
|
|
|
235
242
|
this.#state.lastMessage = NOT_SENT;
|
|
236
243
|
if (this.#settings.features.doneMarker && hadPrompt) {
|
|
237
244
|
this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
|
|
238
|
-
word: this.#settings.features.randomizeDoneMarker ?
|
|
245
|
+
word: this.#settings.features.randomizeDoneMarker ? this.#state.workingText.pastTense : "Worked",
|
|
239
246
|
elapsedMs,
|
|
240
247
|
tokens: this.#state.confirmTokens,
|
|
241
248
|
midTurnInputs: this.#state.midTurnInputs,
|
|
@@ -262,6 +269,7 @@ export class SessionManager {
|
|
|
262
269
|
const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
|
|
263
270
|
const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
|
|
264
271
|
const borderColor = this.#settings.decorations.doneMarkerBorderColor;
|
|
272
|
+
const markerStyle = this.#settings.decorations.doneMarkerStyle;
|
|
265
273
|
if (borderStyle === "none") return new Text(markerContent);
|
|
266
274
|
|
|
267
275
|
let cachedWidth: number | undefined;
|
|
@@ -270,7 +278,7 @@ export class SessionManager {
|
|
|
270
278
|
render(width: number): string[] {
|
|
271
279
|
if (cachedWidth !== width) {
|
|
272
280
|
cachedWidth = width;
|
|
273
|
-
cachedLines = ["",
|
|
281
|
+
cachedLines = ["", buildCompletionMarkerLine(markerContent, width, theme, borderStyle, borderColor, markerStyle), ""];
|
|
274
282
|
}
|
|
275
283
|
return cachedLines!;
|
|
276
284
|
},
|
|
@@ -297,14 +305,26 @@ export class SessionManager {
|
|
|
297
305
|
description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
|
|
298
306
|
handler: async (_a, ctx) => this.showSettings(ctx),
|
|
299
307
|
});
|
|
308
|
+
if (isSiblingSetupEnabled()) registerSetupCommand(this.#pi);
|
|
300
309
|
}
|
|
301
310
|
|
|
302
311
|
private usable(ctx: ExtensionContext): boolean {
|
|
303
312
|
return !!ctx.hasUI && ctx.mode !== "print";
|
|
304
313
|
}
|
|
305
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Mid-stream inputs (steer/follow-up) must pass through untouched: re-sending them via
|
|
317
|
+
* sendMessage(deliverAs) bypasses Pi's queue state, so the "Steering:"/"Follow-up:" rows
|
|
318
|
+
* below the editor never render (issue #2).
|
|
319
|
+
*/
|
|
306
320
|
private shouldDecorate(event: InputEvent): boolean {
|
|
307
|
-
return
|
|
321
|
+
return (
|
|
322
|
+
!event.streamingBehavior &&
|
|
323
|
+
event.source !== "extension" &&
|
|
324
|
+
!!event.text.trim() &&
|
|
325
|
+
!event.images?.length &&
|
|
326
|
+
!/^[\/!?:]/.test(event.text)
|
|
327
|
+
);
|
|
308
328
|
}
|
|
309
329
|
|
|
310
330
|
/**
|
|
@@ -359,11 +379,15 @@ export class SessionManager {
|
|
|
359
379
|
if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
|
|
360
380
|
}
|
|
361
381
|
|
|
382
|
+
private pickWorkingWord(): WorkingTextSelection {
|
|
383
|
+
return pickWorkingTextSelection(this.#settings.wordPacks, [...this.#bundledPacks, ...this.#userPacks]);
|
|
384
|
+
}
|
|
385
|
+
|
|
362
386
|
private resetTurn(now: number): void {
|
|
363
387
|
const state = this.#state;
|
|
364
388
|
state.startTime = now;
|
|
365
389
|
state.shimmerOrigin = now;
|
|
366
|
-
state.
|
|
390
|
+
state.workingText = this.pickWorkingWord();
|
|
367
391
|
state.confirmTokens = 0;
|
|
368
392
|
state.liveTokens = 0;
|
|
369
393
|
state.activityMeter.reset();
|
|
@@ -405,7 +429,7 @@ export class SessionManager {
|
|
|
405
429
|
return;
|
|
406
430
|
}
|
|
407
431
|
|
|
408
|
-
const word = features.substituteDefaultMessage ? state.
|
|
432
|
+
const word = features.substituteDefaultMessage ? state.workingText.text : DEFAULT_WORKING_WORD;
|
|
409
433
|
const styled = decorations.shimmer
|
|
410
434
|
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
|
|
411
435
|
: ctx.ui.theme.fg("text", word);
|
|
@@ -468,11 +492,12 @@ export class SessionManager {
|
|
|
468
492
|
return;
|
|
469
493
|
}
|
|
470
494
|
|
|
495
|
+
this.#userPacks = loadUserWordPacks();
|
|
471
496
|
const before = this.indicatorFingerprint();
|
|
472
|
-
const preview = new PreviewRenderer(ctx);
|
|
497
|
+
const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
|
|
473
498
|
const result = await showMenu<Record<string, boolean | string>>(ctx, {
|
|
474
499
|
title: "Pi Topping: Settings",
|
|
475
|
-
sections: buildMenuSections(this.#settings),
|
|
500
|
+
sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
|
|
476
501
|
hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
|
|
477
502
|
preview: preview.render.bind(preview),
|
|
478
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
|
|
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
|
|
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,
|
|
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
|
+
}
|
package/src/toppings.ts
ADDED
|
@@ -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
|
+
}
|