@underactive/pi-topping 0.2.0 → 0.3.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 +44 -38
- package/index.ts +1 -1
- package/package.json +3 -3
- package/{activity-meter.ts → src/activity-meter.ts} +9 -2
- package/{format.ts → src/format.ts} +26 -11
- package/src/menu.ts +497 -0
- package/src/preview.ts +61 -0
- package/src/prompt-decorator.ts +77 -0
- package/src/session.ts +370 -0
- package/src/settings.ts +174 -0
- package/menu.ts +0 -355
- package/preview.ts +0 -47
- package/session.ts +0 -43
- package/settings.ts +0 -215
- /package/{words.ts → src/words.ts} +0 -0
package/menu.ts
DELETED
|
@@ -1,355 +0,0 @@
|
|
|
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) {
|
|
140
|
-
if (matchesKey(data, Key.enter)) {
|
|
141
|
-
this.done({ applied: true, values: { ...this.values } });
|
|
142
|
-
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
143
|
-
this.done({ applied: false, values: { ...this.initialValues } });
|
|
144
|
-
}
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Map input to a normalized key name.
|
|
149
|
-
let mappedKey: string | undefined;
|
|
150
|
-
for (const k of [Key.up, Key.down, Key.space, Key.enter, Key.escape]) {
|
|
151
|
-
if (matchesKey(data, k)) {
|
|
152
|
-
mappedKey = k;
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (!mappedKey && matchesKey(data, Key.ctrl("c"))) mappedKey = Key.escape;
|
|
157
|
-
|
|
158
|
-
const keyActions: Record<string, () => void> = {
|
|
159
|
-
[Key.up]: () => {
|
|
160
|
-
this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
|
|
161
|
-
this.invalidate();
|
|
162
|
-
},
|
|
163
|
-
[Key.down]: () => {
|
|
164
|
-
this.cursor = (this.cursor + 1) % this.flat.length;
|
|
165
|
-
this.invalidate();
|
|
166
|
-
},
|
|
167
|
-
[Key.space]: () => {
|
|
168
|
-
const item = this.flat[this.cursor]!;
|
|
169
|
-
this.values[item.id] = !this.values[item.id];
|
|
170
|
-
this.invalidate();
|
|
171
|
-
},
|
|
172
|
-
[Key.enter]: () => this.done({ applied: true, values: { ...this.values } }),
|
|
173
|
-
[Key.escape]: () => this.done({ applied: false, values: { ...this.initialValues } }),
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const handler = mappedKey ? keyActions[mappedKey] : undefined;
|
|
177
|
-
if (handler) handler();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
invalidate(): void {
|
|
181
|
-
this.cachedWidth = undefined;
|
|
182
|
-
this.cachedLines = undefined;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
render(width: number): string[] {
|
|
186
|
-
if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
|
|
187
|
-
const lines = this.buildLines(width);
|
|
188
|
-
this.cachedWidth = width;
|
|
189
|
-
this.cachedLines = lines;
|
|
190
|
-
return lines;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
private preferredWidth(previewLines: string[] | undefined): number {
|
|
194
|
-
const candidates: number[] = [MIN_WIDTH - 2, 5 + this.title.length];
|
|
195
|
-
if (previewLines && previewLines.length > 0) {
|
|
196
|
-
candidates.push(3 + "Preview".length);
|
|
197
|
-
// +1 for the leading space rendered in front of each preview line.
|
|
198
|
-
for (const line of previewLines) candidates.push(visibleWidth(line) + 1);
|
|
199
|
-
}
|
|
200
|
-
for (const section of this.sections) {
|
|
201
|
-
candidates.push(3 + section.title.length);
|
|
202
|
-
for (const item of section.items) {
|
|
203
|
-
// 8 fixed left columns + label + 2-space default gap + "OFF" (3) + 2 trailing.
|
|
204
|
-
candidates.push(8 + item.label.length + 2 + 3 + 2);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
candidates.push(2 + this.hints.join(" ").length);
|
|
208
|
-
return 2 + Math.max(...candidates);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private samplePreview(): string[] | undefined {
|
|
212
|
-
return this.previewFn ? this.previewFn(this.values, this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0) : undefined;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
|
|
216
|
-
if (!previewLines?.length) return [];
|
|
217
|
-
return [this.renderSectionDivider("Preview", innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
private buildToggleSections(innerWidth: number): string[] {
|
|
221
|
-
let flatIndex = 0;
|
|
222
|
-
return this.sections.flatMap(section => [
|
|
223
|
-
this.renderSectionDivider(section.title, innerWidth),
|
|
224
|
-
...section.items.map(item => this.renderItemRow(item, flatIndex++ === this.cursor, innerWidth)),
|
|
225
|
-
this.renderBlankRow(innerWidth),
|
|
226
|
-
]);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private buildFooter(innerWidth: number): string[] {
|
|
230
|
-
return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)];
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
private buildLines(maxWidth: number): string[] {
|
|
234
|
-
const previewLines = this.samplePreview();
|
|
235
|
-
const boxWidth = Math.max(0, Math.min(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, this.preferredWidth(previewLines))), maxWidth));
|
|
236
|
-
const innerWidth = Math.max(0, boxWidth - 2);
|
|
237
|
-
return [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth), ...this.buildToggleSections(innerWidth), ...this.buildFooter(innerWidth)].map(line => truncateToWidth(line, boxWidth, ""));
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private wrap(left: string, content: string, right: string): string {
|
|
241
|
-
const th = this.theme;
|
|
242
|
-
return th.fg("accent", left) + content + th.fg("accent", right);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Render a filled horizontal line with optional title. */
|
|
246
|
-
private renderFilledLine(
|
|
247
|
-
left: string,
|
|
248
|
-
right: string,
|
|
249
|
-
fillChar: string,
|
|
250
|
-
innerWidth: number,
|
|
251
|
-
title?: string,
|
|
252
|
-
titlePrefix?: string,
|
|
253
|
-
titleSuffix?: string,
|
|
254
|
-
boldTitle?: boolean,
|
|
255
|
-
): string {
|
|
256
|
-
const th = this.theme;
|
|
257
|
-
if (!title) {
|
|
258
|
-
const content = th.fg("accent", fillChar.repeat(innerWidth));
|
|
259
|
-
return this.wrap(left, content, right);
|
|
260
|
-
}
|
|
261
|
-
const prefix = titlePrefix ?? "";
|
|
262
|
-
const suffix = titleSuffix ?? "";
|
|
263
|
-
const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
|
|
264
|
-
const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
|
|
265
|
-
const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
|
|
266
|
-
const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
|
|
267
|
-
const content = th.fg("accent", `${prefix}${styledTitle}${suffix}${fillChar.repeat(fillCount)}`);
|
|
268
|
-
return this.wrap(left, content, right);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
private renderTopBorder(innerWidth: number): string {
|
|
272
|
-
return this.renderFilledLine("\u2554", "\u2557", "\u2550", innerWidth, this.title, "\u2550[ ", " ]", true);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
private renderBottomBorder(innerWidth: number): string {
|
|
276
|
-
const counter = `[ ${this.cursor + 1}/${this.flat.length} ]`;
|
|
277
|
-
const fillCount = Math.max(0, innerWidth - counter.length);
|
|
278
|
-
const content = `${"\u2550".repeat(fillCount)}${counter}`;
|
|
279
|
-
return this.wrap("\u255a", this.theme.fg("accent", content), "\u255d");
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
private renderSectionDivider(title: string, innerWidth: number): string {
|
|
283
|
-
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth, title, "\u2500 ", " \u2500");
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
private renderSeparator(innerWidth: number): string {
|
|
287
|
-
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private renderBlankRow(innerWidth: number): string {
|
|
291
|
-
return this.wrap("\u2551", " ".repeat(innerWidth), "\u2551");
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
|
|
295
|
-
private renderContentRow(content: string, innerWidth: number): string {
|
|
296
|
-
const shown = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth) : content;
|
|
297
|
-
const pad = Math.max(0, innerWidth - visibleWidth(shown));
|
|
298
|
-
return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private renderHintsRow(innerWidth: number): string {
|
|
302
|
-
const plain = ` ${this.hints.join(" ")}`;
|
|
303
|
-
return this.renderContentRow(this.theme.fg("dim", plain), innerWidth);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
|
|
307
|
-
const th = this.theme;
|
|
308
|
-
const value = this.values[item.id]!;
|
|
309
|
-
const marker = selected ? "\u25b8" : " ";
|
|
310
|
-
const box = value ? "\u25a0" : " ";
|
|
311
|
-
const stateWord = value ? "ON" : "OFF";
|
|
312
|
-
const rightPlain = `${stateWord} `;
|
|
313
|
-
const fixedLeftLen = 8; // " " + marker(1) + " " + "[" + box(1) + "]" + " "
|
|
314
|
-
const minGap = 1;
|
|
315
|
-
const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - minGap);
|
|
316
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
317
|
-
|
|
318
|
-
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
319
|
-
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
320
|
-
|
|
321
|
-
const markerColored = selected ? th.fg("accent", marker) : marker;
|
|
322
|
-
const boxColored = value ? th.fg("success", box) : th.fg("muted", box);
|
|
323
|
-
const labelColored = th.fg("text", label);
|
|
324
|
-
const stateColored = value ? th.fg("success", stateWord) : th.fg("muted", stateWord);
|
|
325
|
-
|
|
326
|
-
const content = ` ${markerColored} [${boxColored}] ${labelColored}${" ".repeat(gap)}${stateColored} `;
|
|
327
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Show a modal box-drawing toggle menu and resolve once the user applies
|
|
333
|
-
* (Enter) or cancels (Escape / Ctrl+C) it.
|
|
334
|
-
*
|
|
335
|
-
* Requires TUI mode; in any other mode this resolves immediately with
|
|
336
|
-
* `applied: false` and the menu's initial values, doing nothing visible.
|
|
337
|
-
*/
|
|
338
|
-
export async function showMenu<T extends Record<string, boolean>>(
|
|
339
|
-
ctx: ExtensionCommandContext,
|
|
340
|
-
config: MenuConfig,
|
|
341
|
-
): Promise<MenuResult<T>> {
|
|
342
|
-
const initialValues = Object.fromEntries(
|
|
343
|
-
config.sections.flatMap((section) => section.items.map((item) => [item.id, item.value])),
|
|
344
|
-
) as T;
|
|
345
|
-
|
|
346
|
-
if (ctx.mode !== "tui") {
|
|
347
|
-
return { applied: false, values: initialValues };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
return ctx.ui.custom<MenuResult<T>>(
|
|
351
|
-
(tui, theme, _keybindings, done) =>
|
|
352
|
-
new MenuComponent(config, theme, done as (result: MenuResult<Record<string, boolean>>) => void, tui),
|
|
353
|
-
{ overlay: true },
|
|
354
|
-
);
|
|
355
|
-
}
|
package/preview.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
|
|
3
|
-
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString } from "./format.ts";
|
|
4
|
-
import { pickRandomWord } from "./words.ts";
|
|
5
|
-
|
|
6
|
-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
-
const METER_INTERVAL_MS = 100;
|
|
8
|
-
const METER_PERIOD_MS = 2400;
|
|
9
|
-
const METER_PEAK_RATE = 46;
|
|
10
|
-
const TOKEN_RATE_PER_SEC = 28;
|
|
11
|
-
const DEFAULT_WORKING_WORD = "Working…";
|
|
12
|
-
|
|
13
|
-
function meterRate(elapsedMs: number): number {
|
|
14
|
-
return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Stateful, per-menu live preview renderer. */
|
|
18
|
-
export class PreviewRenderer {
|
|
19
|
-
readonly #word = pickRandomWord();
|
|
20
|
-
readonly #meter = new ActivityMeter();
|
|
21
|
-
#lastMeterUpdate = 0;
|
|
22
|
-
readonly #ctx: ExtensionContext;
|
|
23
|
-
|
|
24
|
-
constructor(ctx: ExtensionContext) { this.#ctx = ctx; }
|
|
25
|
-
|
|
26
|
-
render(values: Record<string, boolean>, elapsedMs: number): string[] {
|
|
27
|
-
this.#meter.setDirection(values.meterDirection_rtl ? "rtl" : "ltr");
|
|
28
|
-
if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
|
|
29
|
-
this.#meter.push(rateToLevel(meterRate(elapsedMs)));
|
|
30
|
-
this.#lastMeterUpdate = elapsedMs;
|
|
31
|
-
}
|
|
32
|
-
const features = {
|
|
33
|
-
substituteDefaultMessage: values.substituteDefaultMessage ?? true,
|
|
34
|
-
elapsedTime: values.elapsedTime ?? true,
|
|
35
|
-
outputTokens: values.outputTokens ?? true,
|
|
36
|
-
};
|
|
37
|
-
const decorations = { shimmer: values.shimmer ?? true, tokenActivityMonitor: values.tokenActivityMonitor ?? true };
|
|
38
|
-
const spinner = values.animatedSpinner
|
|
39
|
-
? this.#ctx.ui.theme.fg("accent", SPINNER_FRAMES[Math.floor(elapsedMs / 80) % SPINNER_FRAMES.length]!)
|
|
40
|
-
: "";
|
|
41
|
-
if (isFullyDefaultAppearance(features, decorations)) return [[spinner, this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD)].filter(Boolean).join(" ")];
|
|
42
|
-
const word = features.substituteDefaultMessage ? this.#word : DEFAULT_WORKING_WORD;
|
|
43
|
-
const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme) : this.#ctx.ui.theme.fg("text", word);
|
|
44
|
-
const meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme)) : "";
|
|
45
|
-
return [[spinner, buildWorkingMessage(this.#ctx.ui.theme, styledWord, meter, formatElapsed(elapsedMs), formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC))), features)].filter(Boolean).join(" ")];
|
|
46
|
-
}
|
|
47
|
-
}
|
package/session.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
-
import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
|
|
4
|
-
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, StreamingWordCounter } from "./format.ts";
|
|
5
|
-
import { showMenu } from "./menu.ts";
|
|
6
|
-
import { applyMenuResult, buildMenuSections, loadSettings, saveSettings } from "./settings.ts";
|
|
7
|
-
import { PreviewRenderer } from "./preview.ts";
|
|
8
|
-
import { pickRandomWord, pickRawWord } from "./words.ts";
|
|
9
|
-
|
|
10
|
-
const DONE_ENTRY_TYPE = "pi-topping-done";
|
|
11
|
-
const DEFAULT_WORKING_WORD = "Working…";
|
|
12
|
-
const SHIMMER_INTERVAL = 50;
|
|
13
|
-
const METER_INTERVAL_MS = 100;
|
|
14
|
-
export interface DoneEntryData { word: string; elapsedMs: number; }
|
|
15
|
-
export interface SessionState { startTime: number; currentWord: string; confirmTokens: number; liveTokens: number; shimmerOrigin: number; activityMeter: ActivityMeter; rateTracker: TokRateTracker; lastActivityMeterUpdate: number; timer: ReturnType<typeof setInterval> | null; busy: boolean; }
|
|
16
|
-
export function makeFreshState(): SessionState { return { startTime: 0, currentWord: "", confirmTokens: 0, liveTokens: 0, shimmerOrigin: 0, activityMeter: new ActivityMeter(), rateTracker: new TokRateTracker(), lastActivityMeterUpdate: 0, timer: null, busy: false }; }
|
|
17
|
-
|
|
18
|
-
/** Owns mutable extension state and Pi lifecycle registrations. */
|
|
19
|
-
export class SessionManager {
|
|
20
|
-
#counter = new StreamingWordCounter(); #state = makeFreshState(); #settings = loadSettings(); #currentCtx: ExtensionContext | null = null;
|
|
21
|
-
readonly #pi: ExtensionAPI;
|
|
22
|
-
constructor(pi: ExtensionAPI) { this.#pi = pi; }
|
|
23
|
-
install(): void {
|
|
24
|
-
this.#pi.on("session_start", async (_e, ctx) => { this.stopTimer(); this.#counter.reset(); this.#currentCtx = null; this.#state = makeFreshState(); this.#settings = loadSettings(); this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection); this.#currentCtx = ctx; if (this.usable(ctx)) this.applyIndicator(ctx); });
|
|
25
|
-
this.#pi.on("input", async (_e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx)) this.resetTurn(Date.now()); });
|
|
26
|
-
this.#pi.on("agent_start", async (_e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx)) return; if (!this.#state.startTime) this.resetTurn(Date.now()); this.#state.busy = true; this.startTimer(); this.tick(); });
|
|
27
|
-
this.#pi.on("message_start", async (e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx) && e.message.role === "assistant") { this.#state.liveTokens = 0; this.#counter.reset(); } });
|
|
28
|
-
this.#pi.on("message_update", async (e, ctx) => { this.#currentCtx = ctx; const a = e.assistantMessageEvent; if (this.usable(ctx) && a && (a.type === "text_delta" || a.type === "thinking_delta")) this.#state.liveTokens += this.#counter.count(a.delta, a.type); });
|
|
29
|
-
this.#pi.on("message_end", async (e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx) || e.message.role !== "assistant") return; this.#state.confirmTokens += e.message.usage?.output ?? this.#state.liveTokens; this.#state.liveTokens = 0; this.#counter.reset(); });
|
|
30
|
-
this.#pi.on("tool_execution_start", async (_e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx)) { this.#state.currentWord = pickRandomWord(); this.#state.shimmerOrigin = Date.now(); } });
|
|
31
|
-
this.#pi.on("agent_settled", async (_e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx)) return; const hadPrompt = !!this.#state.startTime; const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0; this.#state.busy = false; this.#state.startTime = 0; this.#counter.reset(); this.stopTimer(); ctx.ui.setWorkingMessage(); this.applyIndicator(ctx); this.#state.activityMeter.reset(); this.#state.rateTracker.reset(); this.#state.lastActivityMeterUpdate = 0; if (this.#settings.features.doneMarker && hadPrompt) this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, { word: pickRawWord().past_tense, elapsedMs }); this.#currentCtx = null; });
|
|
32
|
-
this.#pi.on("session_shutdown", async () => this.stopTimer());
|
|
33
|
-
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => entry.data ? new Text(`${theme.fg("text", "π")}${theme.fg("dim", ` ${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}`)}`) : undefined);
|
|
34
|
-
this.#pi.registerCommand("topping-settings", { description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.", handler: async (_a, ctx) => this.showSettings(ctx) });
|
|
35
|
-
}
|
|
36
|
-
private usable(ctx: ExtensionContext): boolean { return !!ctx.hasUI && ctx.mode !== "print"; }
|
|
37
|
-
private applyIndicator(ctx: ExtensionContext): void { ctx.ui.setWorkingIndicator(this.#settings.decorations.animatedSpinner ? undefined : { frames: [] }); }
|
|
38
|
-
private stopTimer(): void { if (this.#state.timer) { clearInterval(this.#state.timer); this.#state.timer = null; } }
|
|
39
|
-
private startTimer(): void { if (!this.#state.timer) this.#state.timer = setInterval(() => this.tick(), SHIMMER_INTERVAL); }
|
|
40
|
-
private resetTurn(now: number): void { const s = this.#state; s.startTime = now; s.shimmerOrigin = now; s.currentWord = pickRandomWord(); s.confirmTokens = 0; s.liveTokens = 0; s.activityMeter.reset(); s.rateTracker.reset(); s.lastActivityMeterUpdate = 0; this.#counter.reset(); }
|
|
41
|
-
private tick(): void { const ctx = this.#currentCtx, s = this.#state; if (!s.busy || !ctx) return; const now = Date.now(), total = s.confirmTokens + s.liveTokens; if (now - s.lastActivityMeterUpdate >= METER_INTERVAL_MS) { s.activityMeter.push(rateToLevel(s.rateTracker.sample(total, now))); s.lastActivityMeterUpdate = now; } const f = this.#settings.features, d = this.#settings.decorations; if (isFullyDefaultAppearance(f, d)) return ctx.ui.setWorkingMessage(); const word = f.substituteDefaultMessage ? s.currentWord : DEFAULT_WORKING_WORD; const styled = d.shimmer ? shimmerString(word, now - s.shimmerOrigin, ctx.ui.theme) : ctx.ui.theme.fg("text", word); const meter = d.tokenActivityMonitor ? s.activityMeter.render((l, c) => ActivityMeter.colorizeCell(l, c, ctx.ui.theme)) : ""; ctx.ui.setWorkingMessage(buildWorkingMessage(ctx.ui.theme, styled, meter, formatElapsed(now - s.startTime), formatTokens(total), f)); }
|
|
42
|
-
private async showSettings(ctx: ExtensionCommandContext): Promise<void> { if (ctx.mode !== "tui") { ctx.ui.notify("/topping-settings requires TUI mode", "error"); return; } const before = this.#settings.decorations.animatedSpinner; const preview = new PreviewRenderer(ctx); const result = await showMenu<Record<string, boolean>>(ctx, { title: "Pi Topping: Settings", sections: buildMenuSections(this.#settings), hints: ["↑↓ move", "␣ toggle", "⏎ apply", "esc cancel"], preview: preview.render.bind(preview) }); if (!result.applied) return; this.#settings = applyMenuResult(this.#settings, result.values); saveSettings(this.#settings); if (before !== this.#settings.decorations.animatedSpinner) this.applyIndicator(ctx); this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection); ctx.ui.notify("Pi Topping settings saved", "info"); }
|
|
43
|
-
}
|
package/settings.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* settings.ts
|
|
3
|
-
*
|
|
4
|
-
* Persistent, per-user settings for pi-topping, stored at
|
|
5
|
-
* `~/.pi/agent/pi-topping/settings.json`. All seven 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
|
-
import type { MenuSection } from "./menu.ts";
|
|
14
|
-
|
|
15
|
-
export interface DecoratorSettings {
|
|
16
|
-
decorations: {
|
|
17
|
-
animatedSpinner: boolean;
|
|
18
|
-
shimmer: boolean;
|
|
19
|
-
tokenActivityMonitor: boolean;
|
|
20
|
-
meterDirection: "ltr" | "rtl";
|
|
21
|
-
};
|
|
22
|
-
features: {
|
|
23
|
-
substituteDefaultMessage: boolean;
|
|
24
|
-
elapsedTime: boolean;
|
|
25
|
-
outputTokens: boolean;
|
|
26
|
-
doneMarker: boolean;
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
31
|
-
decorations: {
|
|
32
|
-
animatedSpinner: true,
|
|
33
|
-
shimmer: true,
|
|
34
|
-
tokenActivityMonitor: true,
|
|
35
|
-
meterDirection: "ltr",
|
|
36
|
-
},
|
|
37
|
-
features: {
|
|
38
|
-
substituteDefaultMessage: true,
|
|
39
|
-
elapsedTime: true,
|
|
40
|
-
outputTokens: true,
|
|
41
|
-
doneMarker: true,
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
/** Resolve the absolute path to this extension's settings file. */
|
|
46
|
-
export function settingsPath(): string {
|
|
47
|
-
return join(getAgentDir(), "pi-topping", "settings.json");
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* True for plain `{}`-style objects only -- excludes `null`, arrays, and
|
|
52
|
-
* primitives. Used to guard against malformed/hand-edited settings.json
|
|
53
|
-
* content (e.g. `"decorations": "oops"` or `"decorations": [1,2,3]`) before
|
|
54
|
-
* spreading it onto the defaults, so a wrong-shaped field can't leak stray
|
|
55
|
-
* properties (like numeric-string keys from spreading a string) into the
|
|
56
|
-
* merged settings object.
|
|
57
|
-
*/
|
|
58
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Merge persisted JSON values onto a defaults object, accepting both boolean
|
|
64
|
-
* fields and a known enum-typed string field (`meterDirection`). Any unknown
|
|
65
|
-
* or wrong-typed keys are silently ignored so hand-edited settings.json can't
|
|
66
|
-
* corrupt the resulting shape.
|
|
67
|
-
*/
|
|
68
|
-
function mergeDecorations(
|
|
69
|
-
defaults: DecoratorSettings["decorations"],
|
|
70
|
-
parsed: unknown,
|
|
71
|
-
): DecoratorSettings["decorations"] {
|
|
72
|
-
const merged = { ...defaults };
|
|
73
|
-
if (!isPlainObject(parsed)) return merged;
|
|
74
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
75
|
-
if (key === "meterDirection") {
|
|
76
|
-
if (value === "ltr" || value === "rtl") {
|
|
77
|
-
merged.meterDirection = value;
|
|
78
|
-
}
|
|
79
|
-
} else if (typeof value === "boolean" && key in merged) {
|
|
80
|
-
(merged as Record<string, unknown>)[key] = value;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return merged;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function mergeBooleanGroup<T extends Record<string, boolean>>(defaults: T, parsed: unknown): T {
|
|
87
|
-
const merged = { ...defaults } as T;
|
|
88
|
-
if (!isPlainObject(parsed)) return merged;
|
|
89
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
90
|
-
if (typeof value === "boolean" && key in merged) {
|
|
91
|
-
merged[key as keyof T] = value as T[keyof T];
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return merged;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface DecorationBooleanMenuEntry {
|
|
98
|
-
id: "animatedSpinner" | "shimmer" | "tokenActivityMonitor";
|
|
99
|
-
label: string;
|
|
100
|
-
section: "Decorations";
|
|
101
|
-
group: "decorations";
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface FeatureBooleanMenuEntry {
|
|
105
|
-
id: "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "doneMarker";
|
|
106
|
-
label: string;
|
|
107
|
-
section: "Options";
|
|
108
|
-
group: "features";
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
interface DirectionMenuEntry {
|
|
112
|
-
id: "meterDirection_rtl";
|
|
113
|
-
label: string;
|
|
114
|
-
section: "Options";
|
|
115
|
-
group: "decorations";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
type MenuEntry = DecorationBooleanMenuEntry | FeatureBooleanMenuEntry | DirectionMenuEntry;
|
|
119
|
-
|
|
120
|
-
/** The ordered settings-menu schema and its explicit settings conversions. */
|
|
121
|
-
export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
122
|
-
{ id: "animatedSpinner", label: "Animated spinner", section: "Decorations", group: "decorations" },
|
|
123
|
-
{ id: "shimmer", label: "“Working...” text shimmer", section: "Decorations", group: "decorations" },
|
|
124
|
-
{ id: "tokenActivityMonitor", label: "Token activity monitor", section: "Decorations", group: "decorations" },
|
|
125
|
-
{ id: "substituteDefaultMessage", label: "Substitute Pi's “Working…” message", section: "Options", group: "features" },
|
|
126
|
-
{ id: "elapsedTime", label: "Elapsed time since prompt", section: "Options", group: "features" },
|
|
127
|
-
{ id: "outputTokens", label: "Show output tokens", section: "Options", group: "features" },
|
|
128
|
-
{ id: "doneMarker", label: "Show completion marker", section: "Options", group: "features" },
|
|
129
|
-
{ id: "meterDirection_rtl", label: "Scrolling: Right → Left", section: "Options", group: "decorations" },
|
|
130
|
-
];
|
|
131
|
-
|
|
132
|
-
/** Build the ordered toggle-menu sections from persisted settings. */
|
|
133
|
-
export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
|
|
134
|
-
return ["Decorations", "Options"].map((title) => ({
|
|
135
|
-
title,
|
|
136
|
-
items: MENU_ENTRIES.filter((entry) => entry.section === title).map((entry) => ({
|
|
137
|
-
id: entry.id,
|
|
138
|
-
label: entry.label,
|
|
139
|
-
value:
|
|
140
|
-
entry.id === "meterDirection_rtl"
|
|
141
|
-
? settings.decorations.meterDirection === "rtl"
|
|
142
|
-
: entry.group === "decorations"
|
|
143
|
-
? settings.decorations[entry.id]
|
|
144
|
-
: settings.features[entry.id],
|
|
145
|
-
})),
|
|
146
|
-
}));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Clone settings and apply recognized menu values, including direction conversion. */
|
|
150
|
-
export function applyMenuResult(
|
|
151
|
-
settings: DecoratorSettings,
|
|
152
|
-
values: Record<string, boolean>,
|
|
153
|
-
): DecoratorSettings {
|
|
154
|
-
const next = structuredClone(settings);
|
|
155
|
-
for (const entry of MENU_ENTRIES) {
|
|
156
|
-
const value = values[entry.id];
|
|
157
|
-
if (value === undefined) continue;
|
|
158
|
-
if (entry.id === "meterDirection_rtl") {
|
|
159
|
-
next.decorations.meterDirection = value ? "rtl" : "ltr";
|
|
160
|
-
} else if (entry.group === "decorations") {
|
|
161
|
-
next.decorations[entry.id] = value;
|
|
162
|
-
} else {
|
|
163
|
-
next.features[entry.id] = value;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return next;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function cloneDefaults(): DecoratorSettings {
|
|
170
|
-
return structuredClone(DEFAULT_SETTINGS);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Load settings from disk, deep-merging any persisted values onto the
|
|
175
|
-
* defaults. Returns a fresh clone of `DEFAULT_SETTINGS` if the file is
|
|
176
|
-
* missing or contains malformed JSON -- this function never throws.
|
|
177
|
-
*/
|
|
178
|
-
export function loadSettings(): DecoratorSettings {
|
|
179
|
-
const defaults = cloneDefaults();
|
|
180
|
-
let raw: string;
|
|
181
|
-
try {
|
|
182
|
-
raw = readFileSync(settingsPath(), "utf8");
|
|
183
|
-
} catch {
|
|
184
|
-
return defaults;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
const parsed = JSON.parse(raw) as Partial<DecoratorSettings> | null;
|
|
189
|
-
if (!isPlainObject(parsed)) return defaults;
|
|
190
|
-
return {
|
|
191
|
-
decorations: mergeDecorations(defaults.decorations, parsed.decorations),
|
|
192
|
-
features: mergeBooleanGroup(defaults.features, parsed.features),
|
|
193
|
-
};
|
|
194
|
-
} catch {
|
|
195
|
-
return defaults;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Persist settings to disk, creating the parent directory if needed.
|
|
201
|
-
* Writes to a temp file and renames it into place so a crash mid-write
|
|
202
|
-
* (kill -9, OOM, disk full) can never leave `settings.json` truncated or
|
|
203
|
-
* corrupt -- `renameSync` is atomic on POSIX filesystems when source and
|
|
204
|
-
* destination are on the same directory/filesystem, which they always are
|
|
205
|
-
* here. A stale `.tmp` file left behind by a crash is harmless: the next
|
|
206
|
-
* successful `saveSettings()` call overwrites it, and `loadSettings()`
|
|
207
|
-
* never reads it.
|
|
208
|
-
*/
|
|
209
|
-
export function saveSettings(settings: DecoratorSettings): void {
|
|
210
|
-
const path = settingsPath();
|
|
211
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
212
|
-
const tmpPath = `${path}.tmp`;
|
|
213
|
-
writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
214
|
-
renameSync(tmpPath, path);
|
|
215
|
-
}
|
|
File without changes
|