@underactive/pi-topping 0.4.0 → 0.5.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 +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 +163 -66
- 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,15 +145,16 @@ 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
|
},
|
|
122
156
|
},
|
|
123
|
-
{
|
|
124
|
-
triggerTurn: true,
|
|
125
|
-
...(event.streamingBehavior ? { deliverAs: event.streamingBehavior } : {}),
|
|
126
|
-
},
|
|
157
|
+
{ triggerTurn: true },
|
|
127
158
|
);
|
|
128
159
|
return { action: "handled" };
|
|
129
160
|
}
|
|
@@ -165,8 +196,11 @@ export class SessionManager {
|
|
|
165
196
|
const exactTokens = event.message.usage?.output;
|
|
166
197
|
this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
|
|
167
198
|
if (exactTokens !== undefined) {
|
|
199
|
+
// Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
|
|
200
|
+
// those must keep holding/fading across message boundaries to avoid flicker.
|
|
168
201
|
this.#state.rateTracker.reset();
|
|
169
|
-
this.#state.
|
|
202
|
+
this.#state.lastTokenRateSampledAt = 0;
|
|
203
|
+
this.#state.lastTokenRateTotal = this.#state.confirmTokens;
|
|
170
204
|
}
|
|
171
205
|
this.#state.liveTokens = 0;
|
|
172
206
|
this.#counter.reset();
|
|
@@ -194,8 +228,8 @@ export class SessionManager {
|
|
|
194
228
|
ctx.ui.setWorkingMessage();
|
|
195
229
|
this.applyIndicator(ctx);
|
|
196
230
|
this.#state.activityMeter.reset();
|
|
197
|
-
this.#state
|
|
198
|
-
this.#state.
|
|
231
|
+
resetTokenRateState(this.#state);
|
|
232
|
+
this.#state.lastMessage = NOT_SENT;
|
|
199
233
|
if (this.#settings.features.doneMarker && hadPrompt) {
|
|
200
234
|
this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
|
|
201
235
|
word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
|
|
@@ -211,6 +245,39 @@ export class SessionManager {
|
|
|
211
245
|
this.stopTimer();
|
|
212
246
|
};
|
|
213
247
|
|
|
248
|
+
#renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
|
|
249
|
+
if (!entry.data) return undefined;
|
|
250
|
+
const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
|
|
251
|
+
const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
|
|
252
|
+
const features = this.#settings.features;
|
|
253
|
+
const details: string[] = [];
|
|
254
|
+
if (features.doneMarkerTokens && typeof entry.data.tokens === "number") details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
|
|
255
|
+
if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && entry.data.midTurnInputs) {
|
|
256
|
+
details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
|
|
257
|
+
}
|
|
258
|
+
const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
|
|
259
|
+
const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
|
|
260
|
+
const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
|
|
261
|
+
const borderColor = this.#settings.decorations.doneMarkerBorderColor;
|
|
262
|
+
if (borderStyle === "none") return new Text(markerContent);
|
|
263
|
+
|
|
264
|
+
let cachedWidth: number | undefined;
|
|
265
|
+
let cachedLines: string[] | undefined;
|
|
266
|
+
return {
|
|
267
|
+
render(width: number): string[] {
|
|
268
|
+
if (cachedWidth !== width) {
|
|
269
|
+
cachedWidth = width;
|
|
270
|
+
cachedLines = ["", ` ${buildCompletionMarkerLine(markerContent, width - 2, theme, borderStyle, borderColor)}`, ""];
|
|
271
|
+
}
|
|
272
|
+
return cachedLines!;
|
|
273
|
+
},
|
|
274
|
+
invalidate(): void {
|
|
275
|
+
cachedWidth = undefined;
|
|
276
|
+
cachedLines = undefined;
|
|
277
|
+
},
|
|
278
|
+
} satisfies Component;
|
|
279
|
+
}
|
|
280
|
+
|
|
214
281
|
install(): void {
|
|
215
282
|
this.#pi.on("session_start", this.#onSessionStart);
|
|
216
283
|
this.#pi.on("input", this.#onInput);
|
|
@@ -221,22 +288,10 @@ export class SessionManager {
|
|
|
221
288
|
this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
|
|
222
289
|
this.#pi.on("agent_settled", this.#onAgentSettled);
|
|
223
290
|
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
|
-
});
|
|
291
|
+
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
|
|
237
292
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
238
293
|
this.#pi.registerCommand("topping-settings", {
|
|
239
|
-
description: "Configure
|
|
294
|
+
description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
|
|
240
295
|
handler: async (_a, ctx) => this.showSettings(ctx),
|
|
241
296
|
});
|
|
242
297
|
}
|
|
@@ -245,8 +300,19 @@ export class SessionManager {
|
|
|
245
300
|
return !!ctx.hasUI && ctx.mode !== "print";
|
|
246
301
|
}
|
|
247
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Mid-stream inputs (steer/follow-up) must pass through untouched: re-sending them via
|
|
305
|
+
* sendMessage(deliverAs) bypasses Pi's queue state, so the "Steering:"/"Follow-up:" rows
|
|
306
|
+
* below the editor never render (issue #2).
|
|
307
|
+
*/
|
|
248
308
|
private shouldDecorate(event: InputEvent): boolean {
|
|
249
|
-
return
|
|
309
|
+
return (
|
|
310
|
+
!event.streamingBehavior &&
|
|
311
|
+
event.source !== "extension" &&
|
|
312
|
+
!!event.text.trim() &&
|
|
313
|
+
!event.images?.length &&
|
|
314
|
+
!/^[\/!?:]/.test(event.text)
|
|
315
|
+
);
|
|
250
316
|
}
|
|
251
317
|
|
|
252
318
|
/**
|
|
@@ -257,7 +323,7 @@ export class SessionManager {
|
|
|
257
323
|
return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
|
|
258
324
|
}
|
|
259
325
|
|
|
260
|
-
private spinnerColor():
|
|
326
|
+
private spinnerColor(): SettingColor {
|
|
261
327
|
const decorations = this.#settings.decorations;
|
|
262
328
|
return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
|
|
263
329
|
}
|
|
@@ -293,13 +359,10 @@ export class SessionManager {
|
|
|
293
359
|
|
|
294
360
|
const features = this.#settings.features;
|
|
295
361
|
const decorations = this.#settings.decorations;
|
|
296
|
-
let interval
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
: features.elapsedTime
|
|
301
|
-
? ELAPSED_INTERVAL_MS
|
|
302
|
-
: undefined;
|
|
362
|
+
let interval: number | undefined;
|
|
363
|
+
if (decorations.shimmer) interval = SHIMMER_INTERVAL_MS;
|
|
364
|
+
else if (decorations.tokenActivityMonitor || features.outputTokens || features.tokenRate) interval = METER_INTERVAL_MS;
|
|
365
|
+
else if (features.elapsedTime) interval = ELAPSED_INTERVAL_MS;
|
|
303
366
|
if (this.spinnerInMessage()) interval = Math.min(interval ?? SPINNER_FRAME_MS, SPINNER_FRAME_MS);
|
|
304
367
|
if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
|
|
305
368
|
}
|
|
@@ -312,9 +375,9 @@ export class SessionManager {
|
|
|
312
375
|
state.confirmTokens = 0;
|
|
313
376
|
state.liveTokens = 0;
|
|
314
377
|
state.activityMeter.reset();
|
|
315
|
-
state
|
|
316
|
-
state.lastActivityMeterUpdate = 0;
|
|
378
|
+
resetTokenRateState(state);
|
|
317
379
|
state.midTurnInputs = 0;
|
|
380
|
+
state.lastMessage = NOT_SENT;
|
|
318
381
|
this.#counter.reset();
|
|
319
382
|
}
|
|
320
383
|
|
|
@@ -327,25 +390,32 @@ export class SessionManager {
|
|
|
327
390
|
const total = state.confirmTokens + state.liveTokens;
|
|
328
391
|
const features = this.#settings.features;
|
|
329
392
|
const decorations = this.#settings.decorations;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
393
|
+
let hasNewTokenCount = false;
|
|
394
|
+
if ((decorations.tokenActivityMonitor || features.tokenRate) && now - state.lastTokenRateSampledAt >= METER_INTERVAL_MS) {
|
|
395
|
+
// Do not reset the fade for an EMA-only decay while output is quiet.
|
|
396
|
+
hasNewTokenCount = total > state.lastTokenRateTotal;
|
|
397
|
+
state.lastTokenRateTotal = total;
|
|
398
|
+
const tokenRate = state.rateTracker.sample(total, now);
|
|
399
|
+
if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
|
|
400
|
+
state.lastTokenRateSampledAt = now;
|
|
333
401
|
}
|
|
334
402
|
const spinner = this.spinnerInMessage()
|
|
335
403
|
? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
|
|
336
404
|
: "";
|
|
337
405
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
338
|
-
|
|
339
|
-
spinner
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
406
|
+
const msg = spinner
|
|
407
|
+
? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
|
|
408
|
+
: undefined;
|
|
409
|
+
if (msg !== state.lastMessage) {
|
|
410
|
+
state.lastMessage = msg;
|
|
411
|
+
ctx.ui.setWorkingMessage(msg);
|
|
412
|
+
}
|
|
343
413
|
return;
|
|
344
414
|
}
|
|
345
415
|
|
|
346
416
|
const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
|
|
347
417
|
const styled = decorations.shimmer
|
|
348
|
-
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed)
|
|
418
|
+
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
|
|
349
419
|
: ctx.ui.theme.fg("text", word);
|
|
350
420
|
const meter = decorations.tokenActivityMonitor
|
|
351
421
|
? state.activityMeter.render((level, char) =>
|
|
@@ -358,19 +428,46 @@ export class SessionManager {
|
|
|
358
428
|
),
|
|
359
429
|
)
|
|
360
430
|
: "";
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
431
|
+
let tokenRateText = "";
|
|
432
|
+
if (features.tokenRate) {
|
|
433
|
+
const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
|
|
434
|
+
if (latestTokenRate && hasNewTokenCount) {
|
|
435
|
+
state.tokenRateText = latestTokenRate;
|
|
436
|
+
state.tokenRateFadeStartsAt = now + TOKEN_RATE_HOLD_MS;
|
|
437
|
+
} else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
|
|
438
|
+
state.tokenRateText = "";
|
|
439
|
+
}
|
|
440
|
+
tokenRateText = state.tokenRateText;
|
|
441
|
+
}
|
|
442
|
+
const tokenRateSegment = !features.tokenRate
|
|
443
|
+
? ""
|
|
444
|
+
: !tokenRateText
|
|
445
|
+
? ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER)
|
|
446
|
+
: now < state.tokenRateFadeStartsAt
|
|
447
|
+
? ctx.ui.theme.fg(decorations.tokenRateColor, tokenRateText)
|
|
448
|
+
: fadeThemeColorString(
|
|
449
|
+
tokenRateText,
|
|
450
|
+
Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
|
|
451
|
+
ctx.ui.theme,
|
|
452
|
+
decorations.tokenRateColor,
|
|
453
|
+
);
|
|
454
|
+
const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
|
|
455
|
+
const msg = buildWorkingMessage(
|
|
456
|
+
ctx.ui.theme,
|
|
457
|
+
{
|
|
458
|
+
spinner,
|
|
459
|
+
text: styled,
|
|
460
|
+
meter,
|
|
461
|
+
elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
|
|
462
|
+
tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
|
|
463
|
+
tokenRate: tokenRateStyled,
|
|
464
|
+
},
|
|
465
|
+
this.#settings.loaderOrder,
|
|
373
466
|
);
|
|
467
|
+
if (msg !== state.lastMessage) {
|
|
468
|
+
state.lastMessage = msg;
|
|
469
|
+
ctx.ui.setWorkingMessage(msg);
|
|
470
|
+
}
|
|
374
471
|
}
|
|
375
472
|
|
|
376
473
|
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
|
}
|