@underactive/pi-topping 0.4.0 → 0.5.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 +45 -18
- package/package.json +1 -1
- package/src/activity-meter.ts +11 -8
- package/src/format.ts +110 -41
- package/src/menu.ts +15 -17
- package/src/preview.ts +107 -37
- package/src/prompt-decorator.ts +76 -12
- package/src/session.ts +150 -61
- package/src/settings.ts +104 -38
package/src/session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AgentSettledEvent,
|
|
3
3
|
AgentStartEvent,
|
|
4
|
+
CustomEntry,
|
|
4
5
|
ExtensionAPI,
|
|
5
6
|
ExtensionCommandContext,
|
|
6
7
|
ExtensionContext,
|
|
@@ -9,23 +10,33 @@ import type {
|
|
|
9
10
|
InputEventResult,
|
|
10
11
|
SessionShutdownEvent,
|
|
11
12
|
SessionStartEvent,
|
|
13
|
+
Theme,
|
|
12
14
|
} from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
14
16
|
import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
|
|
15
17
|
import {
|
|
16
18
|
buildWorkingMessage,
|
|
19
|
+
DEFAULT_WORKING_WORD,
|
|
20
|
+
dimAttribute,
|
|
21
|
+
ELAPSED_INTERVAL_MS,
|
|
22
|
+
fadeThemeColorString,
|
|
17
23
|
formatElapsed,
|
|
24
|
+
formatTokenRate,
|
|
18
25
|
formatTokens,
|
|
19
26
|
isFullyDefaultAppearance,
|
|
27
|
+
METER_INTERVAL_MS,
|
|
28
|
+
SHIMMER_INTERVAL_MS,
|
|
20
29
|
shimmerString,
|
|
21
30
|
SPINNER_FRAME_MS,
|
|
22
31
|
SPINNER_FRAMES,
|
|
32
|
+
TOKEN_RATE_FADE_SHADE_COUNT,
|
|
33
|
+
TOKEN_RATE_PLACEHOLDER,
|
|
23
34
|
StreamingWordCounter,
|
|
24
35
|
} from "./format.ts";
|
|
25
36
|
import { showMenu } from "./menu.ts";
|
|
26
|
-
import { applyMenuResult, buildMenuSections, loadSettings, saveSettings } from "./settings.ts";
|
|
37
|
+
import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type SettingColor } from "./settings.ts";
|
|
27
38
|
import { PreviewRenderer } from "./preview.ts";
|
|
28
|
-
import { PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
|
|
39
|
+
import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, stripControlChars, type PromptBoxDetails } from "./prompt-decorator.ts";
|
|
29
40
|
import { pickRandomWord, pickRawWord } from "./words.ts";
|
|
30
41
|
|
|
31
42
|
type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
|
|
@@ -34,19 +45,18 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
|
|
|
34
45
|
type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
|
|
35
46
|
|
|
36
47
|
const DONE_ENTRY_TYPE = "pi-topping-done";
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const ELAPSED_INTERVAL_MS = 1_000;
|
|
48
|
+
// Reconciliation resets the EMA at message boundaries, so retain and then fade the last rate to avoid flicker.
|
|
49
|
+
const TOKEN_RATE_HOLD_MS = 1_500;
|
|
50
|
+
const TOKEN_RATE_FADE_MS = 250;
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
interface DoneEntryData {
|
|
43
53
|
word: string;
|
|
44
54
|
elapsedMs: number;
|
|
45
55
|
tokens?: number;
|
|
46
56
|
midTurnInputs?: number;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
|
|
59
|
+
interface SessionState {
|
|
50
60
|
startTime: number;
|
|
51
61
|
currentWord: string;
|
|
52
62
|
confirmTokens: number;
|
|
@@ -54,13 +64,30 @@ export interface SessionState {
|
|
|
54
64
|
shimmerOrigin: number;
|
|
55
65
|
activityMeter: ActivityMeter;
|
|
56
66
|
rateTracker: TokRateTracker;
|
|
57
|
-
|
|
67
|
+
lastTokenRateSampledAt: number;
|
|
68
|
+
lastTokenRateTotal: number;
|
|
69
|
+
tokenRateText: string;
|
|
70
|
+
tokenRateFadeStartsAt: number;
|
|
58
71
|
timer: ReturnType<typeof setInterval> | null;
|
|
59
72
|
busy: boolean;
|
|
60
73
|
midTurnInputs: number;
|
|
74
|
+
/** Last string passed to setWorkingMessage(), or the NOT_SENT sentinel before the first call. */
|
|
75
|
+
lastMessage: string | undefined | typeof NOT_SENT;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
|
-
|
|
78
|
+
/** Sentinel distinguishing "never called setWorkingMessage()" from an explicit `undefined` message. */
|
|
79
|
+
const NOT_SENT = Symbol("not-sent");
|
|
80
|
+
|
|
81
|
+
/** Reset all token-rate tracking fields on `state` to their fresh-turn values. */
|
|
82
|
+
function resetTokenRateState(state: SessionState): void {
|
|
83
|
+
state.rateTracker.reset();
|
|
84
|
+
state.lastTokenRateSampledAt = 0;
|
|
85
|
+
state.lastTokenRateTotal = 0;
|
|
86
|
+
state.tokenRateText = "";
|
|
87
|
+
state.tokenRateFadeStartsAt = 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeFreshState(): SessionState {
|
|
64
91
|
return {
|
|
65
92
|
startTime: 0,
|
|
66
93
|
currentWord: "",
|
|
@@ -69,10 +96,14 @@ export function makeFreshState(): SessionState {
|
|
|
69
96
|
shimmerOrigin: 0,
|
|
70
97
|
activityMeter: new ActivityMeter(),
|
|
71
98
|
rateTracker: new TokRateTracker(),
|
|
72
|
-
|
|
99
|
+
lastTokenRateSampledAt: 0,
|
|
100
|
+
lastTokenRateTotal: 0,
|
|
101
|
+
tokenRateText: "",
|
|
102
|
+
tokenRateFadeStartsAt: 0,
|
|
73
103
|
timer: null,
|
|
74
104
|
busy: false,
|
|
75
105
|
midTurnInputs: 0,
|
|
106
|
+
lastMessage: NOT_SENT,
|
|
76
107
|
};
|
|
77
108
|
}
|
|
78
109
|
|
|
@@ -91,7 +122,6 @@ export class SessionManager {
|
|
|
91
122
|
#onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
92
123
|
this.stopTimer();
|
|
93
124
|
this.#counter.reset();
|
|
94
|
-
this.#currentCtx = null;
|
|
95
125
|
this.#state = makeFreshState();
|
|
96
126
|
this.#settings = loadSettings();
|
|
97
127
|
this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
|
|
@@ -115,7 +145,11 @@ export class SessionManager {
|
|
|
115
145
|
submittedAt: Date.now(),
|
|
116
146
|
showIcon: this.#settings.decorations.promptIcon,
|
|
117
147
|
showTimestamp: this.#settings.decorations.promptTimestamp,
|
|
148
|
+
showProvider: this.#settings.decorations.promptProvider,
|
|
149
|
+
showModel: this.#settings.decorations.promptModel,
|
|
118
150
|
icon: this.#settings.decorations.useNerdFont ? "" : "π",
|
|
151
|
+
provider: ctx.model?.provider,
|
|
152
|
+
model: ctx.model?.id,
|
|
119
153
|
borderColor: this.#settings.decorations.borderColor,
|
|
120
154
|
borderStyle: this.#settings.decorations.borderStyle,
|
|
121
155
|
},
|
|
@@ -165,8 +199,11 @@ export class SessionManager {
|
|
|
165
199
|
const exactTokens = event.message.usage?.output;
|
|
166
200
|
this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
|
|
167
201
|
if (exactTokens !== undefined) {
|
|
202
|
+
// Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
|
|
203
|
+
// those must keep holding/fading across message boundaries to avoid flicker.
|
|
168
204
|
this.#state.rateTracker.reset();
|
|
169
|
-
this.#state.
|
|
205
|
+
this.#state.lastTokenRateSampledAt = 0;
|
|
206
|
+
this.#state.lastTokenRateTotal = this.#state.confirmTokens;
|
|
170
207
|
}
|
|
171
208
|
this.#state.liveTokens = 0;
|
|
172
209
|
this.#counter.reset();
|
|
@@ -194,8 +231,8 @@ export class SessionManager {
|
|
|
194
231
|
ctx.ui.setWorkingMessage();
|
|
195
232
|
this.applyIndicator(ctx);
|
|
196
233
|
this.#state.activityMeter.reset();
|
|
197
|
-
this.#state
|
|
198
|
-
this.#state.
|
|
234
|
+
resetTokenRateState(this.#state);
|
|
235
|
+
this.#state.lastMessage = NOT_SENT;
|
|
199
236
|
if (this.#settings.features.doneMarker && hadPrompt) {
|
|
200
237
|
this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
|
|
201
238
|
word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
|
|
@@ -211,6 +248,39 @@ export class SessionManager {
|
|
|
211
248
|
this.stopTimer();
|
|
212
249
|
};
|
|
213
250
|
|
|
251
|
+
#renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
|
|
252
|
+
if (!entry.data) return undefined;
|
|
253
|
+
const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
|
|
254
|
+
const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
|
|
255
|
+
const features = this.#settings.features;
|
|
256
|
+
const details: string[] = [];
|
|
257
|
+
if (features.doneMarkerTokens && typeof entry.data.tokens === "number") details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
|
|
258
|
+
if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && entry.data.midTurnInputs) {
|
|
259
|
+
details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
|
|
260
|
+
}
|
|
261
|
+
const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
|
|
262
|
+
const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
|
|
263
|
+
const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
|
|
264
|
+
const borderColor = this.#settings.decorations.doneMarkerBorderColor;
|
|
265
|
+
if (borderStyle === "none") return new Text(markerContent);
|
|
266
|
+
|
|
267
|
+
let cachedWidth: number | undefined;
|
|
268
|
+
let cachedLines: string[] | undefined;
|
|
269
|
+
return {
|
|
270
|
+
render(width: number): string[] {
|
|
271
|
+
if (cachedWidth !== width) {
|
|
272
|
+
cachedWidth = width;
|
|
273
|
+
cachedLines = ["", ` ${buildCompletionMarkerLine(markerContent, width - 2, theme, borderStyle, borderColor)}`, ""];
|
|
274
|
+
}
|
|
275
|
+
return cachedLines!;
|
|
276
|
+
},
|
|
277
|
+
invalidate(): void {
|
|
278
|
+
cachedWidth = undefined;
|
|
279
|
+
cachedLines = undefined;
|
|
280
|
+
},
|
|
281
|
+
} satisfies Component;
|
|
282
|
+
}
|
|
283
|
+
|
|
214
284
|
install(): void {
|
|
215
285
|
this.#pi.on("session_start", this.#onSessionStart);
|
|
216
286
|
this.#pi.on("input", this.#onInput);
|
|
@@ -221,22 +291,10 @@ export class SessionManager {
|
|
|
221
291
|
this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
|
|
222
292
|
this.#pi.on("agent_settled", this.#onAgentSettled);
|
|
223
293
|
this.#pi.on("session_shutdown", this.#onSessionShutdown);
|
|
224
|
-
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) =>
|
|
225
|
-
if (!entry.data) return undefined;
|
|
226
|
-
const features = this.#settings.features;
|
|
227
|
-
const details: string[] = [];
|
|
228
|
-
if (features.doneMarkerTokens && entry.data.tokens !== undefined) details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
|
|
229
|
-
if (features.doneMarkerInputs && entry.data.midTurnInputs) {
|
|
230
|
-
details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
|
|
231
|
-
}
|
|
232
|
-
const tail = details.length ? ` (${details.join(" · ")})` : "";
|
|
233
|
-
return new Text(
|
|
234
|
-
`${features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${tail}`)}`,
|
|
235
|
-
);
|
|
236
|
-
});
|
|
294
|
+
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
|
|
237
295
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
238
296
|
this.#pi.registerCommand("topping-settings", {
|
|
239
|
-
description: "Configure
|
|
297
|
+
description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
|
|
240
298
|
handler: async (_a, ctx) => this.showSettings(ctx),
|
|
241
299
|
});
|
|
242
300
|
}
|
|
@@ -257,7 +315,7 @@ export class SessionManager {
|
|
|
257
315
|
return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
|
|
258
316
|
}
|
|
259
317
|
|
|
260
|
-
private spinnerColor():
|
|
318
|
+
private spinnerColor(): SettingColor {
|
|
261
319
|
const decorations = this.#settings.decorations;
|
|
262
320
|
return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
|
|
263
321
|
}
|
|
@@ -293,13 +351,10 @@ export class SessionManager {
|
|
|
293
351
|
|
|
294
352
|
const features = this.#settings.features;
|
|
295
353
|
const decorations = this.#settings.decorations;
|
|
296
|
-
let interval
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
: features.elapsedTime
|
|
301
|
-
? ELAPSED_INTERVAL_MS
|
|
302
|
-
: undefined;
|
|
354
|
+
let interval: number | undefined;
|
|
355
|
+
if (decorations.shimmer) interval = SHIMMER_INTERVAL_MS;
|
|
356
|
+
else if (decorations.tokenActivityMonitor || features.outputTokens || features.tokenRate) interval = METER_INTERVAL_MS;
|
|
357
|
+
else if (features.elapsedTime) interval = ELAPSED_INTERVAL_MS;
|
|
303
358
|
if (this.spinnerInMessage()) interval = Math.min(interval ?? SPINNER_FRAME_MS, SPINNER_FRAME_MS);
|
|
304
359
|
if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
|
|
305
360
|
}
|
|
@@ -312,9 +367,9 @@ export class SessionManager {
|
|
|
312
367
|
state.confirmTokens = 0;
|
|
313
368
|
state.liveTokens = 0;
|
|
314
369
|
state.activityMeter.reset();
|
|
315
|
-
state
|
|
316
|
-
state.lastActivityMeterUpdate = 0;
|
|
370
|
+
resetTokenRateState(state);
|
|
317
371
|
state.midTurnInputs = 0;
|
|
372
|
+
state.lastMessage = NOT_SENT;
|
|
318
373
|
this.#counter.reset();
|
|
319
374
|
}
|
|
320
375
|
|
|
@@ -327,25 +382,32 @@ export class SessionManager {
|
|
|
327
382
|
const total = state.confirmTokens + state.liveTokens;
|
|
328
383
|
const features = this.#settings.features;
|
|
329
384
|
const decorations = this.#settings.decorations;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
385
|
+
let hasNewTokenCount = false;
|
|
386
|
+
if ((decorations.tokenActivityMonitor || features.tokenRate) && now - state.lastTokenRateSampledAt >= METER_INTERVAL_MS) {
|
|
387
|
+
// Do not reset the fade for an EMA-only decay while output is quiet.
|
|
388
|
+
hasNewTokenCount = total > state.lastTokenRateTotal;
|
|
389
|
+
state.lastTokenRateTotal = total;
|
|
390
|
+
const tokenRate = state.rateTracker.sample(total, now);
|
|
391
|
+
if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
|
|
392
|
+
state.lastTokenRateSampledAt = now;
|
|
333
393
|
}
|
|
334
394
|
const spinner = this.spinnerInMessage()
|
|
335
395
|
? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
|
|
336
396
|
: "";
|
|
337
397
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
338
|
-
|
|
339
|
-
spinner
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
398
|
+
const msg = spinner
|
|
399
|
+
? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
|
|
400
|
+
: undefined;
|
|
401
|
+
if (msg !== state.lastMessage) {
|
|
402
|
+
state.lastMessage = msg;
|
|
403
|
+
ctx.ui.setWorkingMessage(msg);
|
|
404
|
+
}
|
|
343
405
|
return;
|
|
344
406
|
}
|
|
345
407
|
|
|
346
408
|
const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
|
|
347
409
|
const styled = decorations.shimmer
|
|
348
|
-
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed)
|
|
410
|
+
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
|
|
349
411
|
: ctx.ui.theme.fg("text", word);
|
|
350
412
|
const meter = decorations.tokenActivityMonitor
|
|
351
413
|
? state.activityMeter.render((level, char) =>
|
|
@@ -358,19 +420,46 @@ export class SessionManager {
|
|
|
358
420
|
),
|
|
359
421
|
)
|
|
360
422
|
: "";
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
423
|
+
let tokenRateText = "";
|
|
424
|
+
if (features.tokenRate) {
|
|
425
|
+
const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
|
|
426
|
+
if (latestTokenRate && hasNewTokenCount) {
|
|
427
|
+
state.tokenRateText = latestTokenRate;
|
|
428
|
+
state.tokenRateFadeStartsAt = now + TOKEN_RATE_HOLD_MS;
|
|
429
|
+
} else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
|
|
430
|
+
state.tokenRateText = "";
|
|
431
|
+
}
|
|
432
|
+
tokenRateText = state.tokenRateText;
|
|
433
|
+
}
|
|
434
|
+
const tokenRateSegment = !features.tokenRate
|
|
435
|
+
? ""
|
|
436
|
+
: !tokenRateText
|
|
437
|
+
? ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER)
|
|
438
|
+
: now < state.tokenRateFadeStartsAt
|
|
439
|
+
? ctx.ui.theme.fg(decorations.tokenRateColor, tokenRateText)
|
|
440
|
+
: fadeThemeColorString(
|
|
441
|
+
tokenRateText,
|
|
442
|
+
Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
|
|
443
|
+
ctx.ui.theme,
|
|
444
|
+
decorations.tokenRateColor,
|
|
445
|
+
);
|
|
446
|
+
const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
|
|
447
|
+
const msg = buildWorkingMessage(
|
|
448
|
+
ctx.ui.theme,
|
|
449
|
+
{
|
|
450
|
+
spinner,
|
|
451
|
+
text: styled,
|
|
452
|
+
meter,
|
|
453
|
+
elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
|
|
454
|
+
tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
|
|
455
|
+
tokenRate: tokenRateStyled,
|
|
456
|
+
},
|
|
457
|
+
this.#settings.loaderOrder,
|
|
373
458
|
);
|
|
459
|
+
if (msg !== state.lastMessage) {
|
|
460
|
+
state.lastMessage = msg;
|
|
461
|
+
ctx.ui.setWorkingMessage(msg);
|
|
462
|
+
}
|
|
374
463
|
}
|
|
375
464
|
|
|
376
465
|
private async showSettings(ctx: ExtensionCommandContext): Promise<void> {
|
package/src/settings.ts
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
|
|
5
5
|
import type { MenuSection } from "./menu.ts";
|
|
6
6
|
|
|
7
|
+
export const SETTING_COLOR_VALUES = ["accent", "border", "borderAccent", "success", "error", "warning"] as const;
|
|
8
|
+
export type SettingColor = (typeof SETTING_COLOR_VALUES)[number];
|
|
9
|
+
|
|
10
|
+
export const BORDER_STYLE_VALUES = ["double", "single", "rounded", "heavy"] as const;
|
|
11
|
+
export type BorderStyle = (typeof BORDER_STYLE_VALUES)[number];
|
|
12
|
+
export const DONE_MARKER_BORDER_STYLE_VALUES = [...BORDER_STYLE_VALUES, "none"] as const;
|
|
13
|
+
export type DoneMarkerBorderStyle = (typeof DONE_MARKER_BORDER_STYLE_VALUES)[number];
|
|
14
|
+
|
|
15
|
+
export function isBorderStyle(value: unknown): value is BorderStyle {
|
|
16
|
+
return typeof value === "string" && BORDER_STYLE_VALUES.some(style => style === value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isDoneMarkerBorderStyle(value: unknown): value is DoneMarkerBorderStyle {
|
|
20
|
+
return typeof value === "string" && DONE_MARKER_BORDER_STYLE_VALUES.some(style => style === value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSettingColor(value: unknown): value is SettingColor {
|
|
24
|
+
return typeof value === "string" && SETTING_COLOR_VALUES.some(color => color === value);
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
export interface DecoratorSettings {
|
|
8
28
|
decorations: {
|
|
9
29
|
animatedSpinner: boolean;
|
|
10
30
|
shimmer: boolean;
|
|
31
|
+
shimmerInverted: boolean;
|
|
11
32
|
shimmerDirection: "ltr" | "rtl";
|
|
12
33
|
shimmerDirectionEnabled: boolean;
|
|
13
34
|
shimmerSpeed: "slow" | "normal" | "fast";
|
|
@@ -16,23 +37,30 @@ export interface DecoratorSettings {
|
|
|
16
37
|
meterDirection: "ltr" | "rtl";
|
|
17
38
|
meterDirectionEnabled: boolean;
|
|
18
39
|
decorateUserPrompt: boolean;
|
|
19
|
-
borderColor:
|
|
40
|
+
borderColor: SettingColor;
|
|
20
41
|
borderColorEnabled: boolean;
|
|
21
|
-
borderStyle:
|
|
42
|
+
borderStyle: BorderStyle;
|
|
22
43
|
borderStyleEnabled: boolean;
|
|
23
|
-
|
|
44
|
+
doneMarkerBorderStyle: DoneMarkerBorderStyle;
|
|
45
|
+
doneMarkerBorderColor: SettingColor;
|
|
46
|
+
spinnerColor: SettingColor;
|
|
24
47
|
spinnerColorEnabled: boolean;
|
|
25
|
-
meterColor:
|
|
48
|
+
meterColor: SettingColor;
|
|
26
49
|
meterColorEnabled: boolean;
|
|
27
50
|
meterDimmed: boolean;
|
|
51
|
+
tokenRateColor: SettingColor;
|
|
52
|
+
tokenRateDimmed: boolean;
|
|
28
53
|
promptIcon: boolean;
|
|
29
54
|
promptTimestamp: boolean;
|
|
55
|
+
promptProvider: boolean;
|
|
56
|
+
promptModel: boolean;
|
|
30
57
|
useNerdFont: boolean;
|
|
31
58
|
};
|
|
32
59
|
features: {
|
|
33
60
|
substituteDefaultMessage: boolean;
|
|
34
61
|
elapsedTime: boolean;
|
|
35
62
|
outputTokens: boolean;
|
|
63
|
+
tokenRate: boolean;
|
|
36
64
|
doneMarker: boolean;
|
|
37
65
|
doneMarkerIcon: boolean;
|
|
38
66
|
randomizeDoneMarker: boolean;
|
|
@@ -43,8 +71,8 @@ export interface DecoratorSettings {
|
|
|
43
71
|
}
|
|
44
72
|
|
|
45
73
|
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
46
|
-
decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "
|
|
47
|
-
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
|
|
74
|
+
decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
|
|
75
|
+
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
|
|
48
76
|
loaderOrder: [...DEFAULT_LOADER_ORDER],
|
|
49
77
|
};
|
|
50
78
|
|
|
@@ -56,6 +84,7 @@ const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
|
|
|
56
84
|
meter: "Token activity monitor",
|
|
57
85
|
elapsed: "Elapsed time",
|
|
58
86
|
tokens: "Output tokens",
|
|
87
|
+
tokenRate: "Token rate",
|
|
59
88
|
};
|
|
60
89
|
|
|
61
90
|
/**
|
|
@@ -80,12 +109,15 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
|
|
|
80
109
|
const merged = { ...defaults };
|
|
81
110
|
if (!isPlainObject(parsed)) return merged;
|
|
82
111
|
for (const [key, value] of Object.entries(parsed)) {
|
|
83
|
-
if (!(key
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
if (key === "
|
|
87
|
-
if (key
|
|
88
|
-
if (key
|
|
112
|
+
if (!Object.hasOwn(merged, key)) continue;
|
|
113
|
+
let valid: boolean | string | undefined;
|
|
114
|
+
if (typeof merged[key] === "boolean" && typeof value === "boolean") valid = value;
|
|
115
|
+
else if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) valid = value;
|
|
116
|
+
else if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) valid = value;
|
|
117
|
+
else if (key.endsWith("Color") && isSettingColor(value)) valid = value;
|
|
118
|
+
else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
|
|
119
|
+
else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
|
|
120
|
+
if (valid !== undefined) (merged as Record<string, boolean | string>)[key] = valid;
|
|
89
121
|
}
|
|
90
122
|
return merged;
|
|
91
123
|
}
|
|
@@ -110,6 +142,18 @@ function fromCycleValue(value: string): string {
|
|
|
110
142
|
return value;
|
|
111
143
|
}
|
|
112
144
|
|
|
145
|
+
/** Convert a persisted or menu direction to a safe preview direction. */
|
|
146
|
+
export function fromCycleDirection(value: unknown): "ltr" | "rtl" {
|
|
147
|
+
const stored = typeof value === "string" ? fromCycleValue(value) : "";
|
|
148
|
+
return stored === "rtl" ? "rtl" : "ltr";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Convert a persisted or menu speed to a safe preview speed. */
|
|
152
|
+
export function fromCycleSpeed(value: unknown): "slow" | "normal" | "fast" {
|
|
153
|
+
const stored = typeof value === "string" ? fromCycleValue(value) : "";
|
|
154
|
+
return stored === "slow" || stored === "fast" ? stored : "normal";
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
|
|
114
158
|
type DecorationSettings = DecoratorSettings["decorations"];
|
|
115
159
|
type FeatureSettings = DecoratorSettings["features"];
|
|
@@ -120,23 +164,32 @@ type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureS
|
|
|
120
164
|
type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
|
|
121
165
|
export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
122
166
|
{ id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" },
|
|
123
|
-
{ id: "
|
|
124
|
-
{ id: "
|
|
167
|
+
{ id: "borderStyle", label: "Border style", section: "User Prompt", group: "decorations", key: "borderStyle", cycleValues: BORDER_STYLE_VALUES, cycleEnabledBy: "borderStyleEnabled", cycleDisabledValue: "double" },
|
|
168
|
+
{ id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "borderAccent" },
|
|
125
169
|
{ id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
|
|
126
170
|
{ id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" },
|
|
171
|
+
{ id: "promptProvider", label: "Provider", section: "User Prompt", group: "decorations", key: "promptProvider" },
|
|
172
|
+
{ id: "promptModel", label: "Model", section: "User Prompt", group: "decorations", key: "promptModel" },
|
|
127
173
|
{ id: "animatedSpinner", label: "Animated spinner", section: "“Working” Loader", group: "decorations", key: "animatedSpinner" },
|
|
128
|
-
{ id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues:
|
|
174
|
+
{ id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
|
|
129
175
|
{ id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "“Working” Loader", group: "features", key: "substituteDefaultMessage" },
|
|
130
176
|
{ id: "shimmer", label: "Text shimmer", section: "“Working” Loader", group: "decorations", key: "shimmer" },
|
|
177
|
+
{ id: "shimmerInverted", label: "Invert shimmer", section: "“Working” Loader", group: "decorations", key: "shimmerInverted" },
|
|
131
178
|
{ id: "shimmerDirection", label: "Text shimmer direction", section: "“Working” Loader", group: "decorations", key: "shimmerDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "Left to Right" },
|
|
132
179
|
{ id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["Slow", "Normal", "Fast"], cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "Normal" },
|
|
133
180
|
{ id: "tokenActivityMonitor", label: "Token activity monitor", section: "“Working” Loader", group: "decorations", key: "tokenActivityMonitor" },
|
|
134
|
-
{ id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues:
|
|
135
|
-
{ id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "
|
|
181
|
+
{ id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: SETTING_COLOR_VALUES, cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
|
|
182
|
+
{ id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Right to Left" },
|
|
136
183
|
{ id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
|
|
137
184
|
{ id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
|
|
138
185
|
{ id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" },
|
|
186
|
+
// id differs from key: the Elements Order row already owns "tokenRate" in the menu's shared value namespace.
|
|
187
|
+
{ id: "showTokenRate", label: "Token rate", section: "“Working” Loader", group: "features", key: "tokenRate" },
|
|
188
|
+
{ id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: SETTING_COLOR_VALUES },
|
|
189
|
+
{ id: "tokenRateDimmed", label: "Token rate dimmed", section: "“Working” Loader", group: "decorations", key: "tokenRateDimmed" },
|
|
139
190
|
{ id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
|
|
191
|
+
{ id: "doneMarkerBorderStyle", label: "Border style", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderStyle", cycleValues: DONE_MARKER_BORDER_STYLE_VALUES },
|
|
192
|
+
{ id: "doneMarkerBorderColor", label: "Border color", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderColor", cycleValues: SETTING_COLOR_VALUES },
|
|
140
193
|
{ id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
|
|
141
194
|
{ id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
|
|
142
195
|
{ id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
|
|
@@ -162,10 +215,15 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
|
|
|
162
215
|
case "borderColor":
|
|
163
216
|
case "spinnerColor":
|
|
164
217
|
case "meterColor":
|
|
165
|
-
|
|
218
|
+
case "tokenRateColor":
|
|
219
|
+
case "doneMarkerBorderColor":
|
|
220
|
+
if (isSettingColor(stored)) decorations[key] = stored;
|
|
166
221
|
return;
|
|
167
222
|
case "borderStyle":
|
|
168
|
-
if (stored
|
|
223
|
+
if (isBorderStyle(stored)) decorations[key] = stored;
|
|
224
|
+
return;
|
|
225
|
+
case "doneMarkerBorderStyle":
|
|
226
|
+
if (isDoneMarkerBorderStyle(stored)) decorations[key] = stored;
|
|
169
227
|
return;
|
|
170
228
|
case "shimmerDirection":
|
|
171
229
|
case "meterDirection":
|
|
@@ -180,17 +238,17 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
|
|
|
180
238
|
}
|
|
181
239
|
}
|
|
182
240
|
|
|
183
|
-
function
|
|
241
|
+
function buildSection(title: MenuSectionName, settings: DecoratorSettings): MenuSection {
|
|
184
242
|
return { title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) };
|
|
185
243
|
}
|
|
186
244
|
|
|
187
245
|
export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
|
|
188
246
|
return [
|
|
189
|
-
|
|
190
|
-
|
|
247
|
+
buildSection("User Prompt", settings),
|
|
248
|
+
buildSection("“Working” Loader", settings),
|
|
191
249
|
{ title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
|
|
192
|
-
|
|
193
|
-
|
|
250
|
+
buildSection("Completion Marker", settings),
|
|
251
|
+
buildSection("Options", settings),
|
|
194
252
|
];
|
|
195
253
|
}
|
|
196
254
|
|
|
@@ -212,27 +270,35 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
|
|
|
212
270
|
return next;
|
|
213
271
|
}
|
|
214
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Read settings.json, merging valid leaves over the defaults and falling back
|
|
275
|
+
* to defaults on any read/parse error. When a cycle setting's enabled flag is
|
|
276
|
+
* off, its stored value resets to the menu's disabled default so stale choices
|
|
277
|
+
* don't resurface on re-enable.
|
|
278
|
+
*/
|
|
215
279
|
export function loadSettings(): DecoratorSettings {
|
|
216
280
|
try {
|
|
217
281
|
const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
|
|
218
282
|
if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
|
|
219
283
|
const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder) };
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
["meterColor", "meterColorEnabled", "accent"],
|
|
225
|
-
["shimmerDirection", "shimmerDirectionEnabled", "ltr"],
|
|
226
|
-
["meterDirection", "meterDirectionEnabled", "ltr"],
|
|
227
|
-
["shimmerSpeed", "shimmerSpeedEnabled", "normal"],
|
|
228
|
-
];
|
|
229
|
-
for (const [key, enabledKey, resetValue] of gated) {
|
|
230
|
-
if (!settings.decorations[enabledKey]) (settings.decorations as unknown as Record<string, string>)[key] = resetValue;
|
|
284
|
+
for (const entry of MENU_ENTRIES) {
|
|
285
|
+
if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
|
|
286
|
+
setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
|
|
287
|
+
}
|
|
231
288
|
}
|
|
232
289
|
return settings;
|
|
233
290
|
} catch { return structuredClone(DEFAULT_SETTINGS); }
|
|
234
291
|
}
|
|
292
|
+
|
|
235
293
|
export function saveSettings(settings: DecoratorSettings): void {
|
|
236
|
-
const path = settingsPath();
|
|
237
|
-
|
|
294
|
+
const path = settingsPath();
|
|
295
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
296
|
+
const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
297
|
+
try {
|
|
298
|
+
writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { flag: "wx" });
|
|
299
|
+
renameSync(tmpPath, path);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
try { unlinkSync(tmpPath); } catch { /* tmp file was never created */ }
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
238
304
|
}
|