@underactive/pi-topping 0.2.1 → 0.3.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 +44 -38
- package/package.json +2 -2
- package/src/activity-meter.ts +9 -2
- package/src/format.ts +6 -1
- package/src/menu.ts +218 -76
- package/src/preview.ts +50 -36
- package/src/prompt-decorator.ts +77 -0
- package/src/session.ts +356 -26
- package/src/settings.ts +118 -159
package/src/preview.ts
CHANGED
|
@@ -1,47 +1,61 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PreviewResult } from "./menu.ts";
|
|
2
3
|
import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
|
|
3
|
-
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString } from "./format.ts";
|
|
4
|
+
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, SPINNER_FRAMES } from "./format.ts";
|
|
5
|
+
import { buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
4
6
|
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;
|
|
7
|
+
const METER_INTERVAL_MS = 100, METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
11
8
|
const DEFAULT_WORKING_WORD = "Working…";
|
|
9
|
+
const PROMPT_IDS = new Set(["decorateUserPrompt", "borderColor", "promptIcon", "promptTimestamp"]);
|
|
10
|
+
const MARKER_IDS = new Set(["doneMarker", "doneMarkerIcon", "randomizeDoneMarker", "doneMarkerTokens"]);
|
|
11
|
+
function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
|
|
12
12
|
|
|
13
|
-
|
|
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. */
|
|
13
|
+
/** Stateful, per-menu preview renderer that follows the active settings section. */
|
|
18
14
|
export class PreviewRenderer {
|
|
19
|
-
readonly #word = pickRandomWord();
|
|
20
|
-
readonly #meter = new ActivityMeter();
|
|
21
|
-
#lastMeterUpdate = 0;
|
|
15
|
+
readonly #word = pickRandomWord(); readonly #meter = new ActivityMeter(); #lastMeterUpdate = 0;
|
|
22
16
|
readonly #ctx: ExtensionContext;
|
|
23
|
-
|
|
24
17
|
constructor(ctx: ExtensionContext) { this.#ctx = ctx; }
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
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(" ")];
|
|
18
|
+
render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string): PreviewResult {
|
|
19
|
+
if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values);
|
|
20
|
+
if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values);
|
|
21
|
+
if (activeItemId === "useNerdFont") return { lines: [`Icon preview: ${values.useNerdFont ? "" : "π"}`] };
|
|
22
|
+
return { lines: ["", this.loaderPreview(values, elapsedMs), ""], nextRefreshInMs: 80 };
|
|
23
|
+
}
|
|
24
|
+
private loaderPreview(values: Record<string, boolean | string>, elapsedMs: number): string {
|
|
25
|
+
this.#meter.setDirection(values.meterDirection === "Right to Left" ? "rtl" : "ltr");
|
|
26
|
+
if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) { this.#meter.push(rateToLevel(meterRate(elapsedMs))); this.#lastMeterUpdate = elapsedMs; }
|
|
27
|
+
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false };
|
|
28
|
+
const decorations = { shimmer: values.shimmer !== false, tokenActivityMonitor: values.tokenActivityMonitor !== false };
|
|
29
|
+
const spinnerColor = (values.spinnerColorEnabled === false ? "accent" : values.spinnerColor === "border" || values.spinnerColor === "borderAccent" ? values.spinnerColor : "accent") as "accent" | "border" | "borderAccent";
|
|
30
|
+
const spinner = values.animatedSpinner ? this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / 80) % SPINNER_FRAMES.length]!) : "";
|
|
31
|
+
if (isFullyDefaultAppearance(features, decorations)) return [spinner, this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD)].filter(Boolean).join(" ");
|
|
42
32
|
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
|
|
45
|
-
|
|
33
|
+
const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme, values.shimmerDirection === "Right to Left" ? "rtl" : "ltr") : this.#ctx.ui.theme.fg("text", word);
|
|
34
|
+
const meterColor = (values.meterColorEnabled === false ? "accent" : values.meterColor === "border" || values.meterColor === "borderAccent" ? values.meterColor : "accent") as "accent" | "border" | "borderAccent";
|
|
35
|
+
const meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed !== false)) : "";
|
|
36
|
+
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(" ");
|
|
37
|
+
}
|
|
38
|
+
private promptPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
39
|
+
if (!values.decorateUserPrompt) return { lines: ["User prompt decoration is disabled."] };
|
|
40
|
+
|
|
41
|
+
const borderColor = values.borderColor;
|
|
42
|
+
const timestamp = values.promptTimestamp ? Date.now() : undefined;
|
|
43
|
+
const lines = buildPromptBoxLines("ping", timestamp, 70, this.#ctx.ui.theme, {
|
|
44
|
+
showIcon: values.promptIcon as boolean,
|
|
45
|
+
showTimestamp: values.promptTimestamp as boolean,
|
|
46
|
+
icon: values.useNerdFont ? "" : "π",
|
|
47
|
+
borderColor: borderColor === "accent" || borderColor === "border" || borderColor === "borderAccent" ? borderColor : "accent",
|
|
48
|
+
});
|
|
49
|
+
return timestamp === undefined
|
|
50
|
+
? { lines }
|
|
51
|
+
: { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
|
|
52
|
+
}
|
|
53
|
+
private markerPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
54
|
+
if (!values.doneMarker) return { lines: ["Completion marker is disabled."] };
|
|
55
|
+
const th = this.#ctx.ui.theme;
|
|
56
|
+
const icon = values.doneMarkerIcon ? `${values.useNerdFont ? "" : "π"} ` : "";
|
|
57
|
+
const word = (values.randomizeDoneMarker ? "Concocted" : "Worked") as string;
|
|
58
|
+
const tail = (values.doneMarkerTokens ? " (↓ 949 tokens)" : "") as string;
|
|
59
|
+
return { lines: ["", `${values.doneMarkerIcon ? th.fg("text", icon) : ""}${th.fg("dim", `${word} for 2m 52s${tail}`)}`, ""] };
|
|
46
60
|
}
|
|
47
61
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export const PROMPT_BOX_TYPE = "pi-topping-prompt";
|
|
5
|
+
|
|
6
|
+
export interface PromptBoxDetails {
|
|
7
|
+
submittedAt?: number;
|
|
8
|
+
showIcon?: boolean;
|
|
9
|
+
showTimestamp?: boolean;
|
|
10
|
+
icon?: string;
|
|
11
|
+
borderColor?: "accent" | "border" | "borderAccent";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type PromptTheme = { fg(color: string, text: string): string };
|
|
15
|
+
|
|
16
|
+
/** Build the rendered lines for a decorated user prompt. */
|
|
17
|
+
export function buildPromptBoxLines(
|
|
18
|
+
content: string,
|
|
19
|
+
submittedAt: number | undefined,
|
|
20
|
+
width: number,
|
|
21
|
+
theme: PromptTheme,
|
|
22
|
+
options: PromptBoxDetails = {},
|
|
23
|
+
): string[] {
|
|
24
|
+
if (width < 10) return [];
|
|
25
|
+
|
|
26
|
+
const border = (text: string) => theme.fg(options.borderColor ?? "accent", text);
|
|
27
|
+
const label = (text: string) => theme.fg("customMessageLabel", text);
|
|
28
|
+
const muted = (text: string) => theme.fg("dim", text);
|
|
29
|
+
const bodyText = (text: string) => theme.fg("customMessageText", text);
|
|
30
|
+
const time = options.showTimestamp === false || !submittedAt
|
|
31
|
+
? ""
|
|
32
|
+
: new Date(submittedAt).toLocaleTimeString("en-GB", {
|
|
33
|
+
hour12: false,
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
second: "2-digit",
|
|
37
|
+
});
|
|
38
|
+
const innerWidth = width - 2;
|
|
39
|
+
const icon = options.showIcon === false ? "" : (options.icon ?? "");
|
|
40
|
+
const title = icon ? `══ ${icon} ` : "";
|
|
41
|
+
const titleLength = visibleWidth(title);
|
|
42
|
+
const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
|
|
43
|
+
const topFill = Math.max(0, innerWidth - titleLength - timeLength);
|
|
44
|
+
const topLine = `${border("╔")}${icon ? `${border("══ ")}${label(icon)}${border(" ")}` : ""}${border("═".repeat(topFill))}${time ? `${border(" ")}${muted(time)}${border(" ═")}` : ""}${border("╗")}`;
|
|
45
|
+
const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
|
|
46
|
+
|
|
47
|
+
if (content) {
|
|
48
|
+
const textWidth = innerWidth - 2;
|
|
49
|
+
for (const rawLine of wrapTextWithAnsi(content, textWidth)) {
|
|
50
|
+
const displayLine = rawLine || " ";
|
|
51
|
+
const padded = `${displayLine}${" ".repeat(Math.max(0, textWidth - visibleWidth(displayLine)))}`;
|
|
52
|
+
const line = `${border("║")}${bodyText(` ${padded} `)}${border("║")}`;
|
|
53
|
+
lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const bottomLine = `${border("╚")}${border("═".repeat(innerWidth))}${border("╝")}`;
|
|
58
|
+
lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
|
|
63
|
+
const content = typeof message.content === "string" ? message.content : "";
|
|
64
|
+
const details = (message.details ?? {}) as PromptBoxDetails;
|
|
65
|
+
const linesByWidth = new Map<number, string[]>();
|
|
66
|
+
return {
|
|
67
|
+
render(width: number): string[] {
|
|
68
|
+
let lines = linesByWidth.get(width);
|
|
69
|
+
if (!lines) {
|
|
70
|
+
lines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
|
|
71
|
+
linesByWidth.set(width, lines);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
},
|
|
75
|
+
invalidate() { linesByWidth.clear(); },
|
|
76
|
+
};
|
|
77
|
+
};
|
package/src/session.ts
CHANGED
|
@@ -1,43 +1,373 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AgentSettledEvent,
|
|
3
|
+
AgentStartEvent,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionCommandContext,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
ExtensionEvent,
|
|
8
|
+
InputEvent,
|
|
9
|
+
InputEventResult,
|
|
10
|
+
SessionShutdownEvent,
|
|
11
|
+
SessionStartEvent,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
13
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
14
|
import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
|
|
4
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
buildWorkingMessage,
|
|
17
|
+
formatElapsed,
|
|
18
|
+
formatTokens,
|
|
19
|
+
isFullyDefaultAppearance,
|
|
20
|
+
shimmerString,
|
|
21
|
+
SPINNER_FRAMES,
|
|
22
|
+
StreamingWordCounter,
|
|
23
|
+
} from "./format.ts";
|
|
5
24
|
import { showMenu } from "./menu.ts";
|
|
6
25
|
import { applyMenuResult, buildMenuSections, loadSettings, saveSettings } from "./settings.ts";
|
|
7
26
|
import { PreviewRenderer } from "./preview.ts";
|
|
27
|
+
import { PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
|
|
8
28
|
import { pickRandomWord, pickRawWord } from "./words.ts";
|
|
9
29
|
|
|
30
|
+
type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
|
|
31
|
+
type MessageUpdateEvent = Extract<ExtensionEvent, { type: "message_update" }>;
|
|
32
|
+
type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
|
|
33
|
+
type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
|
|
34
|
+
|
|
10
35
|
const DONE_ENTRY_TYPE = "pi-topping-done";
|
|
11
36
|
const DEFAULT_WORKING_WORD = "Working…";
|
|
12
37
|
const SHIMMER_INTERVAL = 50;
|
|
13
38
|
const METER_INTERVAL_MS = 100;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export
|
|
39
|
+
const ELAPSED_INTERVAL_MS = 1_000;
|
|
40
|
+
|
|
41
|
+
export interface DoneEntryData {
|
|
42
|
+
word: string;
|
|
43
|
+
elapsedMs: number;
|
|
44
|
+
tokens: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SessionState {
|
|
48
|
+
startTime: number;
|
|
49
|
+
currentWord: string;
|
|
50
|
+
confirmTokens: number;
|
|
51
|
+
liveTokens: number;
|
|
52
|
+
shimmerOrigin: number;
|
|
53
|
+
activityMeter: ActivityMeter;
|
|
54
|
+
rateTracker: TokRateTracker;
|
|
55
|
+
lastActivityMeterUpdate: number;
|
|
56
|
+
timer: ReturnType<typeof setInterval> | null;
|
|
57
|
+
busy: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function makeFreshState(): SessionState {
|
|
61
|
+
return {
|
|
62
|
+
startTime: 0,
|
|
63
|
+
currentWord: "",
|
|
64
|
+
confirmTokens: 0,
|
|
65
|
+
liveTokens: 0,
|
|
66
|
+
shimmerOrigin: 0,
|
|
67
|
+
activityMeter: new ActivityMeter(),
|
|
68
|
+
rateTracker: new TokRateTracker(),
|
|
69
|
+
lastActivityMeterUpdate: 0,
|
|
70
|
+
timer: null,
|
|
71
|
+
busy: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
17
74
|
|
|
18
75
|
/** Owns mutable extension state and Pi lifecycle registrations. */
|
|
19
76
|
export class SessionManager {
|
|
20
|
-
#counter = new StreamingWordCounter();
|
|
77
|
+
#counter = new StreamingWordCounter();
|
|
78
|
+
#state = makeFreshState();
|
|
79
|
+
#settings = loadSettings();
|
|
80
|
+
#currentCtx: ExtensionContext | null = null;
|
|
21
81
|
readonly #pi: ExtensionAPI;
|
|
22
|
-
|
|
82
|
+
|
|
83
|
+
constructor(pi: ExtensionAPI) {
|
|
84
|
+
this.#pi = pi;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
88
|
+
this.stopTimer();
|
|
89
|
+
this.#counter.reset();
|
|
90
|
+
this.#currentCtx = null;
|
|
91
|
+
this.#state = makeFreshState();
|
|
92
|
+
this.#settings = loadSettings();
|
|
93
|
+
this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
|
|
94
|
+
this.#currentCtx = ctx;
|
|
95
|
+
if (this.usable(ctx)) this.applyIndicator(ctx);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
#onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
|
|
99
|
+
this.#currentCtx = ctx;
|
|
100
|
+
if (!this.usable(ctx)) return;
|
|
101
|
+
|
|
102
|
+
this.resetTurn(Date.now());
|
|
103
|
+
if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
|
|
104
|
+
this.#pi.sendMessage<PromptBoxDetails>(
|
|
105
|
+
{
|
|
106
|
+
customType: PROMPT_BOX_TYPE,
|
|
107
|
+
content: event.text,
|
|
108
|
+
display: true,
|
|
109
|
+
details: {
|
|
110
|
+
submittedAt: Date.now(),
|
|
111
|
+
showIcon: this.#settings.decorations.promptIcon,
|
|
112
|
+
showTimestamp: this.#settings.decorations.promptTimestamp,
|
|
113
|
+
icon: this.#settings.decorations.useNerdFont ? "" : "π",
|
|
114
|
+
borderColor: this.#settings.decorations.borderColor,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
triggerTurn: true,
|
|
119
|
+
...(event.streamingBehavior ? { deliverAs: event.streamingBehavior } : {}),
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
return { action: "handled" };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
#onAgentStart = async (_e: AgentStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
127
|
+
this.#currentCtx = ctx;
|
|
128
|
+
if (!this.usable(ctx)) return;
|
|
129
|
+
if (!this.#state.startTime) this.resetTurn(Date.now());
|
|
130
|
+
this.#state.busy = true;
|
|
131
|
+
this.startTimer();
|
|
132
|
+
this.tick();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
#onMessageStart = async (event: MessageStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
136
|
+
this.#currentCtx = ctx;
|
|
137
|
+
if (this.usable(ctx) && event.message.role === "assistant") {
|
|
138
|
+
this.#state.liveTokens = 0;
|
|
139
|
+
this.#counter.reset();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
#onMessageUpdate = async (event: MessageUpdateEvent, ctx: ExtensionContext): Promise<void> => {
|
|
144
|
+
this.#currentCtx = ctx;
|
|
145
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
146
|
+
if (
|
|
147
|
+
this.usable(ctx) &&
|
|
148
|
+
assistantEvent &&
|
|
149
|
+
(assistantEvent.type === "text_delta" || assistantEvent.type === "thinking_delta")
|
|
150
|
+
) {
|
|
151
|
+
this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
#onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
|
|
156
|
+
this.#currentCtx = ctx;
|
|
157
|
+
if (!this.usable(ctx) || event.message.role !== "assistant") return;
|
|
158
|
+
|
|
159
|
+
const exactTokens = event.message.usage?.output;
|
|
160
|
+
this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
|
|
161
|
+
if (exactTokens !== undefined) {
|
|
162
|
+
this.#state.rateTracker.reset();
|
|
163
|
+
this.#state.lastActivityMeterUpdate = 0;
|
|
164
|
+
}
|
|
165
|
+
this.#state.liveTokens = 0;
|
|
166
|
+
this.#counter.reset();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
#onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
170
|
+
this.#currentCtx = ctx;
|
|
171
|
+
if (this.usable(ctx)) {
|
|
172
|
+
this.#state.currentWord = pickRandomWord();
|
|
173
|
+
this.#state.shimmerOrigin = Date.now();
|
|
174
|
+
this.tick();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
#onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
|
|
179
|
+
this.#currentCtx = ctx;
|
|
180
|
+
if (!this.usable(ctx)) return;
|
|
181
|
+
|
|
182
|
+
const hadPrompt = !!this.#state.startTime;
|
|
183
|
+
const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
|
|
184
|
+
this.#state.busy = false;
|
|
185
|
+
this.#state.startTime = 0;
|
|
186
|
+
this.#counter.reset();
|
|
187
|
+
this.stopTimer();
|
|
188
|
+
ctx.ui.setWorkingMessage();
|
|
189
|
+
this.applyIndicator(ctx);
|
|
190
|
+
this.#state.activityMeter.reset();
|
|
191
|
+
this.#state.rateTracker.reset();
|
|
192
|
+
this.#state.lastActivityMeterUpdate = 0;
|
|
193
|
+
if (this.#settings.features.doneMarker && hadPrompt) {
|
|
194
|
+
this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
|
|
195
|
+
word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
|
|
196
|
+
elapsedMs,
|
|
197
|
+
tokens: this.#state.confirmTokens,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
this.#currentCtx = null;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
#onSessionShutdown = async (_e: SessionShutdownEvent, _ctx: ExtensionContext): Promise<void> => {
|
|
204
|
+
this.stopTimer();
|
|
205
|
+
};
|
|
206
|
+
|
|
23
207
|
install(): void {
|
|
24
|
-
this.#pi.on("session_start",
|
|
25
|
-
this.#pi.on("input",
|
|
26
|
-
this.#pi.on("agent_start",
|
|
27
|
-
this.#pi.on("message_start",
|
|
28
|
-
this.#pi.on("message_update",
|
|
29
|
-
this.#pi.on("message_end",
|
|
30
|
-
this.#pi.on("tool_execution_start",
|
|
31
|
-
this.#pi.on("agent_settled",
|
|
32
|
-
this.#pi.on("session_shutdown",
|
|
33
|
-
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
208
|
+
this.#pi.on("session_start", this.#onSessionStart);
|
|
209
|
+
this.#pi.on("input", this.#onInput);
|
|
210
|
+
this.#pi.on("agent_start", this.#onAgentStart);
|
|
211
|
+
this.#pi.on("message_start", this.#onMessageStart);
|
|
212
|
+
this.#pi.on("message_update", this.#onMessageUpdate);
|
|
213
|
+
this.#pi.on("message_end", this.#onMessageEnd);
|
|
214
|
+
this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
|
|
215
|
+
this.#pi.on("agent_settled", this.#onAgentSettled);
|
|
216
|
+
this.#pi.on("session_shutdown", this.#onSessionShutdown);
|
|
217
|
+
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) =>
|
|
218
|
+
entry.data
|
|
219
|
+
? new Text(
|
|
220
|
+
`${this.#settings.features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${this.#settings.features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${this.#settings.features.doneMarkerTokens && entry.data.tokens !== undefined ? ` (↓ ${formatTokens(entry.data.tokens)} tokens)` : ""}`)}`,
|
|
221
|
+
)
|
|
222
|
+
: undefined,
|
|
223
|
+
);
|
|
224
|
+
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
225
|
+
this.#pi.registerCommand("topping-settings", {
|
|
226
|
+
description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
|
|
227
|
+
handler: async (_a, ctx) => this.showSettings(ctx),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private usable(ctx: ExtensionContext): boolean {
|
|
232
|
+
return !!ctx.hasUI && ctx.mode !== "print";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private shouldDecorate(event: InputEvent): boolean {
|
|
236
|
+
return event.source !== "extension" && !!event.text.trim() && !event.images?.length && !/^[\/!?:]/.test(event.text);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private applyIndicator(ctx: ExtensionContext): void {
|
|
240
|
+
const decorations = this.#settings.decorations;
|
|
241
|
+
if (!decorations.animatedSpinner) {
|
|
242
|
+
ctx.ui.setWorkingIndicator({ frames: [] });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const color = decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
|
|
247
|
+
if (color === "accent") {
|
|
248
|
+
ctx.ui.setWorkingIndicator(undefined);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
ctx.ui.setWorkingIndicator({ frames: SPINNER_FRAMES.map((frame) => ctx.ui.theme.fg(color, frame)) });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private stopTimer(): void {
|
|
255
|
+
if (this.#state.timer) {
|
|
256
|
+
clearInterval(this.#state.timer);
|
|
257
|
+
this.#state.timer = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private startTimer(): void {
|
|
262
|
+
if (this.#state.timer) return;
|
|
263
|
+
|
|
264
|
+
const features = this.#settings.features;
|
|
265
|
+
const decorations = this.#settings.decorations;
|
|
266
|
+
const interval = decorations.shimmer
|
|
267
|
+
? SHIMMER_INTERVAL
|
|
268
|
+
: (decorations.tokenActivityMonitor || features.outputTokens)
|
|
269
|
+
? METER_INTERVAL_MS
|
|
270
|
+
: features.elapsedTime
|
|
271
|
+
? ELAPSED_INTERVAL_MS
|
|
272
|
+
: undefined;
|
|
273
|
+
if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private resetTurn(now: number): void {
|
|
277
|
+
const state = this.#state;
|
|
278
|
+
state.startTime = now;
|
|
279
|
+
state.shimmerOrigin = now;
|
|
280
|
+
state.currentWord = pickRandomWord();
|
|
281
|
+
state.confirmTokens = 0;
|
|
282
|
+
state.liveTokens = 0;
|
|
283
|
+
state.activityMeter.reset();
|
|
284
|
+
state.rateTracker.reset();
|
|
285
|
+
state.lastActivityMeterUpdate = 0;
|
|
286
|
+
this.#counter.reset();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private tick(): void {
|
|
290
|
+
const ctx = this.#currentCtx;
|
|
291
|
+
const state = this.#state;
|
|
292
|
+
if (!state.busy || !ctx) return;
|
|
293
|
+
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
const total = state.confirmTokens + state.liveTokens;
|
|
296
|
+
const features = this.#settings.features;
|
|
297
|
+
const decorations = this.#settings.decorations;
|
|
298
|
+
if (decorations.tokenActivityMonitor && now - state.lastActivityMeterUpdate >= METER_INTERVAL_MS) {
|
|
299
|
+
state.activityMeter.push(rateToLevel(state.rateTracker.sample(total, now)));
|
|
300
|
+
state.lastActivityMeterUpdate = now;
|
|
301
|
+
}
|
|
302
|
+
if (isFullyDefaultAppearance(features, decorations)) {
|
|
303
|
+
ctx.ui.setWorkingMessage();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
|
|
308
|
+
const styled = decorations.shimmer
|
|
309
|
+
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection)
|
|
310
|
+
: ctx.ui.theme.fg("text", word);
|
|
311
|
+
const meter = decorations.tokenActivityMonitor
|
|
312
|
+
? state.activityMeter.render((level, char) =>
|
|
313
|
+
ActivityMeter.colorizeCell(
|
|
314
|
+
level,
|
|
315
|
+
char,
|
|
316
|
+
ctx.ui.theme,
|
|
317
|
+
decorations.meterColorEnabled ? decorations.meterColor : "accent",
|
|
318
|
+
decorations.meterDimmed,
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
: "";
|
|
322
|
+
ctx.ui.setWorkingMessage(
|
|
323
|
+
buildWorkingMessage(
|
|
324
|
+
ctx.ui.theme,
|
|
325
|
+
styled,
|
|
326
|
+
meter,
|
|
327
|
+
formatElapsed(now - state.startTime),
|
|
328
|
+
formatTokens(total),
|
|
329
|
+
features,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async showSettings(ctx: ExtensionCommandContext): Promise<void> {
|
|
335
|
+
if (ctx.mode !== "tui") {
|
|
336
|
+
ctx.ui.notify("/topping-settings requires TUI mode", "error");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const before = `${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`;
|
|
341
|
+
const preview = new PreviewRenderer(ctx);
|
|
342
|
+
const result = await showMenu<Record<string, boolean | string>>(ctx, {
|
|
343
|
+
title: "Pi Topping: Settings",
|
|
344
|
+
sections: buildMenuSections(this.#settings),
|
|
345
|
+
hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
|
|
346
|
+
preview: preview.render.bind(preview),
|
|
347
|
+
});
|
|
348
|
+
if (!result.applied) return;
|
|
349
|
+
|
|
350
|
+
const updatedSettings = applyMenuResult(this.#settings, result.values);
|
|
351
|
+
try {
|
|
352
|
+
saveSettings(updatedSettings);
|
|
353
|
+
} catch {
|
|
354
|
+
ctx.ui.notify("Failed to save Pi Topping settings", "error");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.#settings = updatedSettings;
|
|
359
|
+
if (
|
|
360
|
+
before !==
|
|
361
|
+
`${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`
|
|
362
|
+
) {
|
|
363
|
+
this.applyIndicator(ctx);
|
|
364
|
+
}
|
|
365
|
+
this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
|
|
366
|
+
if (this.#state.busy) {
|
|
367
|
+
this.stopTimer();
|
|
368
|
+
this.startTimer();
|
|
369
|
+
this.tick();
|
|
370
|
+
}
|
|
371
|
+
ctx.ui.notify("Pi Topping settings saved", "info");
|
|
372
|
+
}
|
|
43
373
|
}
|