@underactive/pi-topping 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/index.ts +5 -462
- package/package.json +2 -2
- package/{activity-meter.ts → src/activity-meter.ts} +31 -5
- package/src/format.ts +149 -0
- package/{menu.ts → src/menu.ts} +89 -61
- package/src/preview.ts +47 -0
- package/src/session.ts +43 -0
- package/src/settings.ts +215 -0
- package/src/words.ts +207 -0
- package/format.ts +0 -71
- package/settings.ts +0 -106
- package/words.ts +0 -195
package/src/format.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Format an output-token count for the working indicator. */
|
|
4
|
+
export function formatTokens(count: number): string {
|
|
5
|
+
if (count < 1000) return count.toString();
|
|
6
|
+
const UNITS = [
|
|
7
|
+
{ threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
|
|
8
|
+
{ threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
|
|
9
|
+
{ threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
|
|
10
|
+
{ threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
|
|
11
|
+
{ threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
|
|
12
|
+
];
|
|
13
|
+
const unit = UNITS.find(u => count < u.threshold);
|
|
14
|
+
if (unit) {
|
|
15
|
+
const value = unit.decimals > 0
|
|
16
|
+
? (count / unit.divisor).toFixed(unit.decimals)
|
|
17
|
+
: `${Math.round(count / unit.divisor)}`;
|
|
18
|
+
return `${value}${unit.suffix}`;
|
|
19
|
+
}
|
|
20
|
+
return `${(count / 1_000_000_000).toFixed(1)}B`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
|
|
24
|
+
export function formatElapsed(ms: number): string {
|
|
25
|
+
let totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
26
|
+
const parts: string[] = [];
|
|
27
|
+
|
|
28
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
29
|
+
totalSeconds -= days * 86400;
|
|
30
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
31
|
+
totalSeconds -= hours * 3600;
|
|
32
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
33
|
+
const seconds = totalSeconds % 60;
|
|
34
|
+
|
|
35
|
+
if (days > 0) parts.push(`${days}d`);
|
|
36
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
37
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
38
|
+
parts.push(`${seconds}s`);
|
|
39
|
+
|
|
40
|
+
return parts.join(" ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Codex-style light-sweep shimmer using the active Pi theme. */
|
|
44
|
+
export function isFullyDefaultAppearance(features: {
|
|
45
|
+
substituteDefaultMessage: boolean;
|
|
46
|
+
elapsedTime: boolean;
|
|
47
|
+
outputTokens: boolean;
|
|
48
|
+
}, decorations: { shimmer: boolean; tokenActivityMonitor: boolean }): boolean {
|
|
49
|
+
return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Assemble the styled word, meter, and optional detail fields. */
|
|
53
|
+
export function buildWorkingMessage(
|
|
54
|
+
theme: Pick<Theme, "fg">,
|
|
55
|
+
word: string,
|
|
56
|
+
frame: string,
|
|
57
|
+
elapsed: string,
|
|
58
|
+
tokens: string,
|
|
59
|
+
features: { elapsedTime: boolean; outputTokens: boolean },
|
|
60
|
+
): string {
|
|
61
|
+
const parts = [word, frame].filter(Boolean);
|
|
62
|
+
const details = [features.elapsedTime ? elapsed : "", features.outputTokens ? `↓ ${tokens} tokens` : ""].filter(Boolean);
|
|
63
|
+
if (details.length) parts.push(theme.fg("dim", `(${details.join(" · ")})`));
|
|
64
|
+
return parts.join(" ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function shimmerString(
|
|
68
|
+
text: string,
|
|
69
|
+
elapsedMs: number,
|
|
70
|
+
theme: Pick<Theme, "getFgAnsi">,
|
|
71
|
+
): string {
|
|
72
|
+
const chars = [...text];
|
|
73
|
+
if (chars.length === 0) return "";
|
|
74
|
+
const SHIMMER_SWEEP_S = 2.0;
|
|
75
|
+
const SHIMMER_BAND_HALF = 5.0;
|
|
76
|
+
const SHIMMER_PADDING = 10;
|
|
77
|
+
const SHIMMER_BASE = ansiToRgb(theme.getFgAnsi("dim"));
|
|
78
|
+
const SHIMMER_HIGHLIGHT = ansiToRgb(theme.getFgAnsi("text"));
|
|
79
|
+
const period = chars.length + SHIMMER_PADDING * 2;
|
|
80
|
+
const elapsedS = elapsedMs / 1000;
|
|
81
|
+
const pos = ((elapsedS % SHIMMER_SWEEP_S) / SHIMMER_SWEEP_S) * period;
|
|
82
|
+
|
|
83
|
+
return chars
|
|
84
|
+
.map((ch, i) => {
|
|
85
|
+
const dist = Math.abs((i + SHIMMER_PADDING) - pos);
|
|
86
|
+
const t = dist <= SHIMMER_BAND_HALF
|
|
87
|
+
? 0.5 * (1 + Math.cos(Math.PI * dist / SHIMMER_BAND_HALF))
|
|
88
|
+
: 0;
|
|
89
|
+
const alpha = t * 0.9;
|
|
90
|
+
const r = Math.round(SHIMMER_HIGHLIGHT[0] * alpha + SHIMMER_BASE[0] * (1 - alpha));
|
|
91
|
+
const g = Math.round(SHIMMER_HIGHLIGHT[1] * alpha + SHIMMER_BASE[1] * (1 - alpha));
|
|
92
|
+
const b = Math.round(SHIMMER_HIGHLIGHT[2] * alpha + SHIMMER_BASE[2] * (1 - alpha));
|
|
93
|
+
const bold = t > 0.2 ? "\x1b[1m" : "";
|
|
94
|
+
return `${bold}\x1b[38;2;${r};${g};${b}m${ch}\x1b[22m`;
|
|
95
|
+
})
|
|
96
|
+
.join("") + "\x1b[0m";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ansiToRgb(ansi: string): [number, number, number] {
|
|
100
|
+
const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
|
|
101
|
+
if (!match) throw new Error(`Shimmer requires truecolor theme colors, received ${JSON.stringify(ansi)}`);
|
|
102
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Incremental whitespace-boundary word counter.
|
|
107
|
+
*
|
|
108
|
+
* The in-word state is retained across streaming chunks so a word split across
|
|
109
|
+
* multiple deltas is counted exactly once.
|
|
110
|
+
*/
|
|
111
|
+
export class StreamingWordCounter {
|
|
112
|
+
#inWordByStream = new Map<string, boolean>();
|
|
113
|
+
|
|
114
|
+
count(text: string, stream = "default"): number {
|
|
115
|
+
let inWord = this.#inWordByStream.get(stream) ?? false;
|
|
116
|
+
let count = 0;
|
|
117
|
+
for (let i = 0; i < text.length; i++) {
|
|
118
|
+
const code = text.charCodeAt(i);
|
|
119
|
+
if (isWhitespace(code)) {
|
|
120
|
+
inWord = false;
|
|
121
|
+
} else if (!inWord) {
|
|
122
|
+
count++;
|
|
123
|
+
inWord = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
this.#inWordByStream.set(stream, inWord);
|
|
127
|
+
return count;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
reset(): void {
|
|
131
|
+
this.#inWordByStream.clear();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isWhitespace(code: number): boolean {
|
|
136
|
+
return (
|
|
137
|
+
code === 32 ||
|
|
138
|
+
(code >= 9 && code <= 13) ||
|
|
139
|
+
code === 160 ||
|
|
140
|
+
code === 0x1680 ||
|
|
141
|
+
(code >= 0x2000 && code <= 0x200a) ||
|
|
142
|
+
code === 0x2028 ||
|
|
143
|
+
code === 0x2029 ||
|
|
144
|
+
code === 0x202f ||
|
|
145
|
+
code === 0x205f ||
|
|
146
|
+
code === 0x3000 ||
|
|
147
|
+
code === 0xfeff
|
|
148
|
+
);
|
|
149
|
+
}
|
package/{menu.ts → src/menu.ts}
RENAMED
|
@@ -136,23 +136,45 @@ export class MenuComponent implements Component {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
handleInput(data: string): void {
|
|
139
|
-
if (this.flat.length === 0)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
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();
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
invalidate(): void {
|
|
@@ -186,42 +208,33 @@ export class MenuComponent implements Component {
|
|
|
186
208
|
return 2 + Math.max(...candidates);
|
|
187
209
|
}
|
|
188
210
|
|
|
189
|
-
private
|
|
190
|
-
|
|
191
|
-
|
|
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));
|
|
211
|
+
private samplePreview(): string[] | undefined {
|
|
212
|
+
return this.previewFn ? this.previewFn(this.values, this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0) : undefined;
|
|
213
|
+
}
|
|
202
214
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
lines.push(this.renderBlankRow(innerWidth));
|
|
208
|
-
}
|
|
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
|
+
}
|
|
209
219
|
|
|
220
|
+
private buildToggleSections(innerWidth: number): string[] {
|
|
210
221
|
let flatIndex = 0;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
lines.push(this.renderBlankRow(innerWidth));
|
|
218
|
-
}
|
|
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
|
+
}
|
|
219
228
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
229
|
+
private buildFooter(innerWidth: number): string[] {
|
|
230
|
+
return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)];
|
|
231
|
+
}
|
|
223
232
|
|
|
224
|
-
|
|
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, ""));
|
|
225
238
|
}
|
|
226
239
|
|
|
227
240
|
private wrap(left: string, content: string, right: string): string {
|
|
@@ -229,13 +242,34 @@ export class MenuComponent implements Component {
|
|
|
229
242
|
return th.fg("accent", left) + content + th.fg("accent", right);
|
|
230
243
|
}
|
|
231
244
|
|
|
232
|
-
|
|
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 {
|
|
233
256
|
const th = this.theme;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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);
|
|
239
273
|
}
|
|
240
274
|
|
|
241
275
|
private renderBottomBorder(innerWidth: number): string {
|
|
@@ -246,17 +280,11 @@ export class MenuComponent implements Component {
|
|
|
246
280
|
}
|
|
247
281
|
|
|
248
282
|
private renderSectionDivider(title: string, innerWidth: number): string {
|
|
249
|
-
|
|
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");
|
|
283
|
+
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth, title, "\u2500 ", " \u2500");
|
|
255
284
|
}
|
|
256
285
|
|
|
257
286
|
private renderSeparator(innerWidth: number): string {
|
|
258
|
-
|
|
259
|
-
return this.wrap("\u255f", content, "\u2562");
|
|
287
|
+
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth);
|
|
260
288
|
}
|
|
261
289
|
|
|
262
290
|
private renderBlankRow(innerWidth: number): string {
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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/src/session.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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/src/settings.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
}
|