@underactive/pi-topping 0.1.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/LICENSE +21 -0
- package/README.md +81 -0
- package/activity-meter.ts +102 -0
- package/format.ts +71 -0
- package/index.ts +464 -0
- package/menu.ts +327 -0
- package/package.json +41 -0
- package/settings.ts +106 -0
- package/words.ts +195 -0
package/menu.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* menu.ts
|
|
3
|
+
*
|
|
4
|
+
* A reusable, dependency-light box-drawing toggle-menu component for Pi
|
|
5
|
+
* extensions, built on top of `@earendil-works/pi-tui`'s `Component`
|
|
6
|
+
* contract and `ctx.ui.custom()` overlay API.
|
|
7
|
+
*
|
|
8
|
+
* Renders a titled box containing one or more sections of boolean toggle
|
|
9
|
+
* items, plus an optional live-updating preview section driven by a
|
|
10
|
+
* caller-supplied render callback, e.g.:
|
|
11
|
+
*
|
|
12
|
+
* ╔═[ Pi Topping: Settings ]════════════════════════╗
|
|
13
|
+
* ╟─ Preview ─────────────────────────────────────╢
|
|
14
|
+
* ║ ║
|
|
15
|
+
* ║ ⠋ Cerebrating… ⣤⣤⣤⣤ (0m 03s · ↓ 84 tokens) ║
|
|
16
|
+
* ║ ║
|
|
17
|
+
* ╟─ Decorations ───────────────────────────────────╢
|
|
18
|
+
* ║ ▸ [■] Animated spinner ON ║
|
|
19
|
+
* ║ [ ] "Working..." text shimmer OFF ║
|
|
20
|
+
* ║ ║
|
|
21
|
+
* ╟──────────────────────────────────────────────────╢
|
|
22
|
+
* ║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
|
|
23
|
+
* ╚══════════════════════════════════════════[ 1/2 ]═╝
|
|
24
|
+
*
|
|
25
|
+
* Intended to be reused by any extension that needs a simple modal toggle
|
|
26
|
+
* menu; it has no dependency on this extension's own settings shape.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
31
|
+
|
|
32
|
+
export interface MenuItem {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
value: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MenuSection {
|
|
39
|
+
title: string;
|
|
40
|
+
items: MenuItem[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MenuConfig {
|
|
44
|
+
title: string;
|
|
45
|
+
sections: MenuSection[];
|
|
46
|
+
hints?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Optional live preview renderer, shown in its own "Preview" section above
|
|
49
|
+
* the toggle sections. Called on every render with the menu's current
|
|
50
|
+
* (possibly toggled but not-yet-applied) values and the number of
|
|
51
|
+
* milliseconds elapsed since the menu opened, so callers can drive
|
|
52
|
+
* animation (e.g. a shimmer sweep or a scrolling meter). Return one string
|
|
53
|
+
* per preview line; lines may contain ANSI styling and are truncated/
|
|
54
|
+
* padded to fit automatically.
|
|
55
|
+
*
|
|
56
|
+
* When set, the menu re-renders itself on an interval (see
|
|
57
|
+
* `previewIntervalMs`) so the preview animates even without keyboard
|
|
58
|
+
* input.
|
|
59
|
+
*/
|
|
60
|
+
preview?: (values: Record<string, boolean>, elapsedMs: number) => string[];
|
|
61
|
+
/** Interval in ms between preview re-renders when `preview` is set. Default 50. */
|
|
62
|
+
previewIntervalMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MenuResult<T> {
|
|
66
|
+
applied: boolean;
|
|
67
|
+
values: T;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
71
|
+
const MIN_WIDTH = 36;
|
|
72
|
+
const MAX_WIDTH = 76;
|
|
73
|
+
|
|
74
|
+
interface FlatItem {
|
|
75
|
+
id: string;
|
|
76
|
+
label: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Internal Component implementing the box-drawing toggle menu. */
|
|
80
|
+
export class MenuComponent implements Component {
|
|
81
|
+
private readonly theme: Theme;
|
|
82
|
+
private readonly done: (result: MenuResult<Record<string, boolean>>) => void;
|
|
83
|
+
private readonly title: string;
|
|
84
|
+
private readonly sections: MenuSection[];
|
|
85
|
+
private readonly hints: string[];
|
|
86
|
+
private readonly initialValues: Record<string, boolean>;
|
|
87
|
+
private readonly values: Record<string, boolean>;
|
|
88
|
+
private readonly flat: FlatItem[];
|
|
89
|
+
private readonly previewFn: MenuConfig["preview"];
|
|
90
|
+
private readonly previewOrigin: number | undefined;
|
|
91
|
+
private previewTimer: ReturnType<typeof setInterval> | undefined;
|
|
92
|
+
private cursor = 0;
|
|
93
|
+
private cachedWidth: number | undefined;
|
|
94
|
+
private cachedLines: string[] | undefined;
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
config: MenuConfig,
|
|
98
|
+
theme: Theme,
|
|
99
|
+
done: (result: MenuResult<Record<string, boolean>>) => void,
|
|
100
|
+
tui?: TUI,
|
|
101
|
+
) {
|
|
102
|
+
this.theme = theme;
|
|
103
|
+
this.done = done;
|
|
104
|
+
this.title = config.title;
|
|
105
|
+
this.sections = config.sections;
|
|
106
|
+
this.hints = config.hints ?? DEFAULT_HINTS;
|
|
107
|
+
this.values = {};
|
|
108
|
+
this.flat = [];
|
|
109
|
+
for (const section of config.sections) {
|
|
110
|
+
for (const item of section.items) {
|
|
111
|
+
this.values[item.id] = item.value;
|
|
112
|
+
this.flat.push({ id: item.id, label: item.label });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
this.initialValues = { ...this.values };
|
|
116
|
+
|
|
117
|
+
this.previewFn = config.preview;
|
|
118
|
+
if (this.previewFn) {
|
|
119
|
+
this.previewOrigin = Date.now();
|
|
120
|
+
if (tui) {
|
|
121
|
+
const intervalMs = config.previewIntervalMs ?? 50;
|
|
122
|
+
this.previewTimer = setInterval(() => {
|
|
123
|
+
this.invalidate();
|
|
124
|
+
tui.requestRender();
|
|
125
|
+
}, intervalMs);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Stops the preview animation timer, if any. Called automatically when the overlay closes. */
|
|
131
|
+
dispose(): void {
|
|
132
|
+
if (this.previewTimer) {
|
|
133
|
+
clearInterval(this.previewTimer);
|
|
134
|
+
this.previewTimer = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
handleInput(data: string): void {
|
|
139
|
+
if (this.flat.length === 0) return;
|
|
140
|
+
|
|
141
|
+
if (matchesKey(data, Key.up)) {
|
|
142
|
+
this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
|
|
143
|
+
this.invalidate();
|
|
144
|
+
} else if (matchesKey(data, Key.down)) {
|
|
145
|
+
this.cursor = (this.cursor + 1) % this.flat.length;
|
|
146
|
+
this.invalidate();
|
|
147
|
+
} else if (matchesKey(data, Key.space)) {
|
|
148
|
+
const item = this.flat[this.cursor]!;
|
|
149
|
+
this.values[item.id] = !this.values[item.id];
|
|
150
|
+
this.invalidate();
|
|
151
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
152
|
+
this.done({ applied: true, values: { ...this.values } });
|
|
153
|
+
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
154
|
+
this.done({ applied: false, values: { ...this.initialValues } });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
invalidate(): void {
|
|
159
|
+
this.cachedWidth = undefined;
|
|
160
|
+
this.cachedLines = undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
render(width: number): string[] {
|
|
164
|
+
if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
|
|
165
|
+
const lines = this.buildLines(width);
|
|
166
|
+
this.cachedWidth = width;
|
|
167
|
+
this.cachedLines = lines;
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private preferredWidth(previewLines: string[] | undefined): number {
|
|
172
|
+
const candidates: number[] = [MIN_WIDTH - 2, 5 + this.title.length];
|
|
173
|
+
if (previewLines && previewLines.length > 0) {
|
|
174
|
+
candidates.push(3 + "Preview".length);
|
|
175
|
+
// +1 for the leading space rendered in front of each preview line.
|
|
176
|
+
for (const line of previewLines) candidates.push(visibleWidth(line) + 1);
|
|
177
|
+
}
|
|
178
|
+
for (const section of this.sections) {
|
|
179
|
+
candidates.push(3 + section.title.length);
|
|
180
|
+
for (const item of section.items) {
|
|
181
|
+
// 8 fixed left columns + label + 2-space default gap + "OFF" (3) + 2 trailing.
|
|
182
|
+
candidates.push(8 + item.label.length + 2 + 3 + 2);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
candidates.push(2 + this.hints.join(" ").length);
|
|
186
|
+
return 2 + Math.max(...candidates);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private buildLines(maxWidth: number): string[] {
|
|
190
|
+
// Computed once per render so a stateful `preview` callback (e.g. one
|
|
191
|
+
// driving a rolling activity meter) is never sampled twice per frame.
|
|
192
|
+
const previewLines = this.previewFn
|
|
193
|
+
? this.previewFn(this.values, this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0)
|
|
194
|
+
: undefined;
|
|
195
|
+
|
|
196
|
+
const desired = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, this.preferredWidth(previewLines)));
|
|
197
|
+
const boxWidth = Math.max(0, Math.min(desired, maxWidth));
|
|
198
|
+
const innerWidth = Math.max(0, boxWidth - 2);
|
|
199
|
+
|
|
200
|
+
const lines: string[] = [];
|
|
201
|
+
lines.push(this.renderTopBorder(innerWidth));
|
|
202
|
+
|
|
203
|
+
if (previewLines && previewLines.length > 0) {
|
|
204
|
+
lines.push(this.renderSectionDivider("Preview", innerWidth));
|
|
205
|
+
lines.push(this.renderBlankRow(innerWidth));
|
|
206
|
+
for (const line of previewLines) lines.push(this.renderContentRow(` ${line}`, innerWidth));
|
|
207
|
+
lines.push(this.renderBlankRow(innerWidth));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let flatIndex = 0;
|
|
211
|
+
for (const section of this.sections) {
|
|
212
|
+
lines.push(this.renderSectionDivider(section.title, innerWidth));
|
|
213
|
+
for (const item of section.items) {
|
|
214
|
+
lines.push(this.renderItemRow(item, flatIndex === this.cursor, innerWidth));
|
|
215
|
+
flatIndex++;
|
|
216
|
+
}
|
|
217
|
+
lines.push(this.renderBlankRow(innerWidth));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
lines.push(this.renderSeparator(innerWidth));
|
|
221
|
+
lines.push(this.renderHintsRow(innerWidth));
|
|
222
|
+
lines.push(this.renderBottomBorder(innerWidth));
|
|
223
|
+
|
|
224
|
+
return lines.map((line) => truncateToWidth(line, boxWidth, ""));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private wrap(left: string, content: string, right: string): string {
|
|
228
|
+
const th = this.theme;
|
|
229
|
+
return th.fg("accent", left) + content + th.fg("accent", right);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private renderTopBorder(innerWidth: number): string {
|
|
233
|
+
const th = this.theme;
|
|
234
|
+
const maxTitleLen = Math.max(0, innerWidth - 5);
|
|
235
|
+
const title = this.title.length > maxTitleLen ? truncateToWidth(this.title, maxTitleLen) : this.title;
|
|
236
|
+
const fillCount = Math.max(0, innerWidth - 5 - visibleWidth(title));
|
|
237
|
+
const content = th.fg("accent", `\u2550[ ${th.bold(title)} `) + th.fg("accent", `]${"\u2550".repeat(fillCount)}`);
|
|
238
|
+
return this.wrap("\u2554", content, "\u2557");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private renderBottomBorder(innerWidth: number): string {
|
|
242
|
+
const counter = `[ ${this.cursor + 1}/${this.flat.length} ]`;
|
|
243
|
+
const fillCount = Math.max(0, innerWidth - counter.length);
|
|
244
|
+
const content = `${"\u2550".repeat(fillCount)}${counter}`;
|
|
245
|
+
return this.wrap("\u255a", this.theme.fg("accent", content), "\u255d");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private renderSectionDivider(title: string, innerWidth: number): string {
|
|
249
|
+
const th = this.theme;
|
|
250
|
+
const maxTitleLen = Math.max(0, innerWidth - 3);
|
|
251
|
+
const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
|
|
252
|
+
const fillCount = Math.max(0, innerWidth - 3 - visibleWidth(shownTitle));
|
|
253
|
+
const content = th.fg("accent", `\u2500 ${shownTitle} ${"\u2500".repeat(fillCount)}`);
|
|
254
|
+
return this.wrap("\u255f", content, "\u2562");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private renderSeparator(innerWidth: number): string {
|
|
258
|
+
const content = this.theme.fg("accent", "\u2500".repeat(innerWidth));
|
|
259
|
+
return this.wrap("\u255f", content, "\u2562");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private renderBlankRow(innerWidth: number): string {
|
|
263
|
+
return this.wrap("\u2551", " ".repeat(innerWidth), "\u2551");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
|
|
267
|
+
private renderContentRow(content: string, innerWidth: number): string {
|
|
268
|
+
const shown = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth) : content;
|
|
269
|
+
const pad = Math.max(0, innerWidth - visibleWidth(shown));
|
|
270
|
+
return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private renderHintsRow(innerWidth: number): string {
|
|
274
|
+
const plain = ` ${this.hints.join(" ")}`;
|
|
275
|
+
return this.renderContentRow(this.theme.fg("dim", plain), innerWidth);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
|
|
279
|
+
const th = this.theme;
|
|
280
|
+
const value = this.values[item.id]!;
|
|
281
|
+
const marker = selected ? "\u25b8" : " ";
|
|
282
|
+
const box = value ? "\u25a0" : " ";
|
|
283
|
+
const stateWord = value ? "ON" : "OFF";
|
|
284
|
+
const rightPlain = `${stateWord} `;
|
|
285
|
+
const fixedLeftLen = 8; // " " + marker(1) + " " + "[" + box(1) + "]" + " "
|
|
286
|
+
const minGap = 1;
|
|
287
|
+
const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - minGap);
|
|
288
|
+
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
289
|
+
|
|
290
|
+
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
291
|
+
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
292
|
+
|
|
293
|
+
const markerColored = selected ? th.fg("accent", marker) : marker;
|
|
294
|
+
const boxColored = value ? th.fg("success", box) : th.fg("muted", box);
|
|
295
|
+
const labelColored = th.fg("text", label);
|
|
296
|
+
const stateColored = value ? th.fg("success", stateWord) : th.fg("muted", stateWord);
|
|
297
|
+
|
|
298
|
+
const content = ` ${markerColored} [${boxColored}] ${labelColored}${" ".repeat(gap)}${stateColored} `;
|
|
299
|
+
return this.wrap("\u2551", content, "\u2551");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Show a modal box-drawing toggle menu and resolve once the user applies
|
|
305
|
+
* (Enter) or cancels (Escape / Ctrl+C) it.
|
|
306
|
+
*
|
|
307
|
+
* Requires TUI mode; in any other mode this resolves immediately with
|
|
308
|
+
* `applied: false` and the menu's initial values, doing nothing visible.
|
|
309
|
+
*/
|
|
310
|
+
export async function showMenu<T extends Record<string, boolean>>(
|
|
311
|
+
ctx: ExtensionCommandContext,
|
|
312
|
+
config: MenuConfig,
|
|
313
|
+
): Promise<MenuResult<T>> {
|
|
314
|
+
const initialValues = Object.fromEntries(
|
|
315
|
+
config.sections.flatMap((section) => section.items.map((item) => [item.id, item.value])),
|
|
316
|
+
) as T;
|
|
317
|
+
|
|
318
|
+
if (ctx.mode !== "tui") {
|
|
319
|
+
return { applied: false, values: initialValues };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return ctx.ui.custom<MenuResult<T>>(
|
|
323
|
+
(tui, theme, _keybindings, done) =>
|
|
324
|
+
new MenuComponent(config, theme, done as (result: MenuResult<Record<string, boolean>>) => void, tui),
|
|
325
|
+
{ overlay: true },
|
|
326
|
+
);
|
|
327
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@underactive/pi-topping",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Pi extension that replaces the default \"Working...\" loader with a shimmering, randomized activity word, live elapsed timer, and output-token activity meter",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Eric Sison",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/underactive/pi-topping.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/underactive/pi-topping/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/underactive/pi-topping#readme",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.19.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": ["*.ts", "README.md", "LICENSE"],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test tests/*.test.ts",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": ">=0.80.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "0.80.6",
|
|
33
|
+
"@earendil-works/pi-tui": "0.80.6",
|
|
34
|
+
"@types/node": "^24.0.0",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": ["./index.ts"],
|
|
39
|
+
"video": "https://cdn.jsdelivr.net/gh/underactive/pi-topping@main/demo.mp4"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/settings.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings.ts
|
|
3
|
+
*
|
|
4
|
+
* Persistent, per-user settings for pi-topping, stored at
|
|
5
|
+
* `~/.pi/agent/pi-topping/settings.json`. All six toggles
|
|
6
|
+
* default to enabled so behavior is unchanged for users who never open
|
|
7
|
+
* `/topping-settings`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface DecoratorSettings {
|
|
15
|
+
decorations: {
|
|
16
|
+
animatedSpinner: boolean;
|
|
17
|
+
shimmer: boolean;
|
|
18
|
+
tokenActivityMonitor: boolean;
|
|
19
|
+
};
|
|
20
|
+
features: {
|
|
21
|
+
substituteDefaultMessage: boolean;
|
|
22
|
+
elapsedTime: boolean;
|
|
23
|
+
outputTokens: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
28
|
+
decorations: {
|
|
29
|
+
animatedSpinner: true,
|
|
30
|
+
shimmer: true,
|
|
31
|
+
tokenActivityMonitor: true,
|
|
32
|
+
},
|
|
33
|
+
features: {
|
|
34
|
+
substituteDefaultMessage: true,
|
|
35
|
+
elapsedTime: true,
|
|
36
|
+
outputTokens: true,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Resolve the absolute path to this extension's settings file. */
|
|
41
|
+
export function settingsPath(): string {
|
|
42
|
+
return join(getAgentDir(), "pi-topping", "settings.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True for plain `{}`-style objects only -- excludes `null`, arrays, and
|
|
47
|
+
* primitives. Used to guard against malformed/hand-edited settings.json
|
|
48
|
+
* content (e.g. `"decorations": "oops"` or `"decorations": [1,2,3]`) before
|
|
49
|
+
* spreading it onto the defaults, so a wrong-shaped field can't leak stray
|
|
50
|
+
* properties (like numeric-string keys from spreading a string) into the
|
|
51
|
+
* merged settings object.
|
|
52
|
+
*/
|
|
53
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cloneDefaults(): DecoratorSettings {
|
|
58
|
+
return {
|
|
59
|
+
decorations: { ...DEFAULT_SETTINGS.decorations },
|
|
60
|
+
features: { ...DEFAULT_SETTINGS.features },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load settings from disk, deep-merging any persisted values onto the
|
|
66
|
+
* defaults. Returns a fresh clone of `DEFAULT_SETTINGS` if the file is
|
|
67
|
+
* missing or contains malformed JSON -- this function never throws.
|
|
68
|
+
*/
|
|
69
|
+
export function loadSettings(): DecoratorSettings {
|
|
70
|
+
const defaults = cloneDefaults();
|
|
71
|
+
let raw: string;
|
|
72
|
+
try {
|
|
73
|
+
raw = readFileSync(settingsPath(), "utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
return defaults;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw) as Partial<DecoratorSettings> | null;
|
|
80
|
+
if (!isPlainObject(parsed)) return defaults;
|
|
81
|
+
return {
|
|
82
|
+
decorations: { ...defaults.decorations, ...(isPlainObject(parsed.decorations) ? parsed.decorations : undefined) },
|
|
83
|
+
features: { ...defaults.features, ...(isPlainObject(parsed.features) ? parsed.features : undefined) },
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return defaults;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Persist settings to disk, creating the parent directory if needed.
|
|
92
|
+
* Writes to a temp file and renames it into place so a crash mid-write
|
|
93
|
+
* (kill -9, OOM, disk full) can never leave `settings.json` truncated or
|
|
94
|
+
* corrupt -- `renameSync` is atomic on POSIX filesystems when source and
|
|
95
|
+
* destination are on the same directory/filesystem, which they always are
|
|
96
|
+
* here. A stale `.tmp` file left behind by a crash is harmless: the next
|
|
97
|
+
* successful `saveSettings()` call overwrites it, and `loadSettings()`
|
|
98
|
+
* never reads it.
|
|
99
|
+
*/
|
|
100
|
+
export function saveSettings(settings: DecoratorSettings): void {
|
|
101
|
+
const path = settingsPath();
|
|
102
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
103
|
+
const tmpPath = `${path}.tmp`;
|
|
104
|
+
writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
105
|
+
renameSync(tmpPath, path);
|
|
106
|
+
}
|
package/words.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity word list for the randomized "Working..." indicator.
|
|
3
|
+
*
|
|
4
|
+
* A random word is picked on prompt and on tool execution start
|
|
5
|
+
* (see index.ts).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const WORDS: string[] = [
|
|
9
|
+
"Accomplishing",
|
|
10
|
+
"Actioning",
|
|
11
|
+
"Actualizing",
|
|
12
|
+
"Architecting",
|
|
13
|
+
"Baking",
|
|
14
|
+
"Beaming",
|
|
15
|
+
"Beboppin'",
|
|
16
|
+
"Befuddling",
|
|
17
|
+
"Billowing",
|
|
18
|
+
"Blanching",
|
|
19
|
+
"Bloviating",
|
|
20
|
+
"Boogieing",
|
|
21
|
+
"Boondoggling",
|
|
22
|
+
"Booping",
|
|
23
|
+
"Bootstrapping",
|
|
24
|
+
"Brewing",
|
|
25
|
+
"Bunning",
|
|
26
|
+
"Burrowing",
|
|
27
|
+
"Calculating",
|
|
28
|
+
"Canoodling",
|
|
29
|
+
"Caramelizing",
|
|
30
|
+
"Cascading",
|
|
31
|
+
"Catapulting",
|
|
32
|
+
"Cerebrating",
|
|
33
|
+
"Channeling",
|
|
34
|
+
"Channelling",
|
|
35
|
+
"Choreographing",
|
|
36
|
+
"Churning",
|
|
37
|
+
"Clauding",
|
|
38
|
+
"Coalescing",
|
|
39
|
+
"Cogitating",
|
|
40
|
+
"Combobulating",
|
|
41
|
+
"Composing",
|
|
42
|
+
"Computing",
|
|
43
|
+
"Concocting",
|
|
44
|
+
"Considering",
|
|
45
|
+
"Contemplating",
|
|
46
|
+
"Cooking",
|
|
47
|
+
"Crafting",
|
|
48
|
+
"Creating",
|
|
49
|
+
"Crunching",
|
|
50
|
+
"Crystallizing",
|
|
51
|
+
"Cultivating",
|
|
52
|
+
"Deciphering",
|
|
53
|
+
"Deliberating",
|
|
54
|
+
"Determining",
|
|
55
|
+
"Dilly-dallying",
|
|
56
|
+
"Discombobulating",
|
|
57
|
+
"Doing",
|
|
58
|
+
"Doodling",
|
|
59
|
+
"Drizzling",
|
|
60
|
+
"Ebbing",
|
|
61
|
+
"Effecting",
|
|
62
|
+
"Elucidating",
|
|
63
|
+
"Embellishing",
|
|
64
|
+
"Enchanting",
|
|
65
|
+
"Envisioning",
|
|
66
|
+
"Fermenting",
|
|
67
|
+
"Fiddle-faddling",
|
|
68
|
+
"Finagling",
|
|
69
|
+
"Flambéing",
|
|
70
|
+
"Flibbertigibbeting",
|
|
71
|
+
"Flowing",
|
|
72
|
+
"Flummoxing",
|
|
73
|
+
"Fluttering",
|
|
74
|
+
"Forging",
|
|
75
|
+
"Forming",
|
|
76
|
+
"Frolicking",
|
|
77
|
+
"Frosting",
|
|
78
|
+
"Gallivanting",
|
|
79
|
+
"Galloping",
|
|
80
|
+
"Garnishing",
|
|
81
|
+
"Generating",
|
|
82
|
+
"Gesticulating",
|
|
83
|
+
"Germinating",
|
|
84
|
+
"Gitifying",
|
|
85
|
+
"Grooving",
|
|
86
|
+
"Gusting",
|
|
87
|
+
"Harmonizing",
|
|
88
|
+
"Hashing",
|
|
89
|
+
"Hatching",
|
|
90
|
+
"Herding",
|
|
91
|
+
"Honking",
|
|
92
|
+
"Hullaballooing",
|
|
93
|
+
"Hyperspacing",
|
|
94
|
+
"Ideating",
|
|
95
|
+
"Imagining",
|
|
96
|
+
"Improvising",
|
|
97
|
+
"Incubating",
|
|
98
|
+
"Inferring",
|
|
99
|
+
"Infusing",
|
|
100
|
+
"Ionizing",
|
|
101
|
+
"Jitterbugging",
|
|
102
|
+
"Julienning",
|
|
103
|
+
"Kneading",
|
|
104
|
+
"Leavening",
|
|
105
|
+
"Levitating",
|
|
106
|
+
"Lollygagging",
|
|
107
|
+
"Manifesting",
|
|
108
|
+
"Marinating",
|
|
109
|
+
"Meandering",
|
|
110
|
+
"Metamorphosing",
|
|
111
|
+
"Misting",
|
|
112
|
+
"Moonwalking",
|
|
113
|
+
"Moseying",
|
|
114
|
+
"Mulling",
|
|
115
|
+
"Mustering",
|
|
116
|
+
"Musing",
|
|
117
|
+
"Nebulizing",
|
|
118
|
+
"Nesting",
|
|
119
|
+
"Newspapering",
|
|
120
|
+
"Noodling",
|
|
121
|
+
"Nucleating",
|
|
122
|
+
"Orbiting",
|
|
123
|
+
"Orchestrating",
|
|
124
|
+
"Osmosing",
|
|
125
|
+
"Perambulating",
|
|
126
|
+
"Percolating",
|
|
127
|
+
"Perusing",
|
|
128
|
+
"Philosophising",
|
|
129
|
+
"Photosynthesizing",
|
|
130
|
+
"Pollinating",
|
|
131
|
+
"Pondering",
|
|
132
|
+
"Pontificating",
|
|
133
|
+
"Pouncing",
|
|
134
|
+
"Precipitating",
|
|
135
|
+
"Prestidigitating",
|
|
136
|
+
"Processing",
|
|
137
|
+
"Proofing",
|
|
138
|
+
"Propagating",
|
|
139
|
+
"Puttering",
|
|
140
|
+
"Puzzling",
|
|
141
|
+
"Quantumizing",
|
|
142
|
+
"Razzle-dazzling",
|
|
143
|
+
"Razzmatazzing",
|
|
144
|
+
"Recombobulating",
|
|
145
|
+
"Reticulating",
|
|
146
|
+
"Roosting",
|
|
147
|
+
"Ruminating",
|
|
148
|
+
"Sautéing",
|
|
149
|
+
"Scampering",
|
|
150
|
+
"Schlepping",
|
|
151
|
+
"Scurrying",
|
|
152
|
+
"Seasoning",
|
|
153
|
+
"Shenaniganing",
|
|
154
|
+
"Shimmying",
|
|
155
|
+
"Simmering",
|
|
156
|
+
"Skedaddling",
|
|
157
|
+
"Sketching",
|
|
158
|
+
"Slithering",
|
|
159
|
+
"Smooshing",
|
|
160
|
+
"Sock-hopping",
|
|
161
|
+
"Spelunking",
|
|
162
|
+
"Spinning",
|
|
163
|
+
"Sprouting",
|
|
164
|
+
"Stewing",
|
|
165
|
+
"Sublimating",
|
|
166
|
+
"Swirling",
|
|
167
|
+
"Swooping",
|
|
168
|
+
"Symbioting",
|
|
169
|
+
"Synthesizing",
|
|
170
|
+
"Tempering",
|
|
171
|
+
"Thinking",
|
|
172
|
+
"Thundering",
|
|
173
|
+
"Tinkering",
|
|
174
|
+
"Tomfoolering",
|
|
175
|
+
"Topsy-turvying",
|
|
176
|
+
"Transfiguring",
|
|
177
|
+
"Transmuting",
|
|
178
|
+
"Twisting",
|
|
179
|
+
"Undulating",
|
|
180
|
+
"Unfurling",
|
|
181
|
+
"Unravelling",
|
|
182
|
+
"Vibing",
|
|
183
|
+
"Waddling",
|
|
184
|
+
"Wandering",
|
|
185
|
+
"Warping",
|
|
186
|
+
"Whatchamacalliting",
|
|
187
|
+
"Whirlpooling",
|
|
188
|
+
"Whirring",
|
|
189
|
+
"Whisking",
|
|
190
|
+
"Wibbling",
|
|
191
|
+
"Working",
|
|
192
|
+
"Wrangling",
|
|
193
|
+
"Zesting",
|
|
194
|
+
"Zigzagging",
|
|
195
|
+
];
|