@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/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
- function meterRate(elapsedMs: number): number {
14
- return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE;
15
- }
16
-
17
- /** Stateful, per-menu live preview renderer. */
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
- render(values: Record<string, boolean>, elapsedMs: number): string[] {
27
- this.#meter.setDirection(values.meterDirection_rtl ? "rtl" : "ltr");
28
- if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
29
- this.#meter.push(rateToLevel(meterRate(elapsedMs)));
30
- this.#lastMeterUpdate = elapsedMs;
31
- }
32
- const features = {
33
- substituteDefaultMessage: values.substituteDefaultMessage ?? true,
34
- elapsedTime: values.elapsedTime ?? true,
35
- outputTokens: values.outputTokens ?? true,
36
- };
37
- const decorations = { shimmer: values.shimmer ?? true, tokenActivityMonitor: values.tokenActivityMonitor ?? true };
38
- const spinner = values.animatedSpinner
39
- ? this.#ctx.ui.theme.fg("accent", SPINNER_FRAMES[Math.floor(elapsedMs / 80) % SPINNER_FRAMES.length]!)
40
- : "";
41
- if (isFullyDefaultAppearance(features, decorations)) return [[spinner, this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD)].filter(Boolean).join(" ")];
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 meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme)) : "";
45
- return [[spinner, buildWorkingMessage(this.#ctx.ui.theme, styledWord, meter, formatElapsed(elapsedMs), formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC))), features)].filter(Boolean).join(" ")];
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 { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
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 { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, StreamingWordCounter } from "./format.ts";
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
- export interface DoneEntryData { word: string; elapsedMs: number; }
15
- export interface SessionState { startTime: number; currentWord: string; confirmTokens: number; liveTokens: number; shimmerOrigin: number; activityMeter: ActivityMeter; rateTracker: TokRateTracker; lastActivityMeterUpdate: number; timer: ReturnType<typeof setInterval> | null; busy: boolean; }
16
- export function makeFreshState(): SessionState { return { startTime: 0, currentWord: "", confirmTokens: 0, liveTokens: 0, shimmerOrigin: 0, activityMeter: new ActivityMeter(), rateTracker: new TokRateTracker(), lastActivityMeterUpdate: 0, timer: null, busy: false }; }
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(); #state = makeFreshState(); #settings = loadSettings(); #currentCtx: ExtensionContext | null = null;
77
+ #counter = new StreamingWordCounter();
78
+ #state = makeFreshState();
79
+ #settings = loadSettings();
80
+ #currentCtx: ExtensionContext | null = null;
21
81
  readonly #pi: ExtensionAPI;
22
- constructor(pi: ExtensionAPI) { this.#pi = pi; }
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", async (_e, ctx) => { this.stopTimer(); this.#counter.reset(); this.#currentCtx = null; this.#state = makeFreshState(); this.#settings = loadSettings(); this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection); this.#currentCtx = ctx; if (this.usable(ctx)) this.applyIndicator(ctx); });
25
- this.#pi.on("input", async (_e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx)) this.resetTurn(Date.now()); });
26
- this.#pi.on("agent_start", async (_e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx)) return; if (!this.#state.startTime) this.resetTurn(Date.now()); this.#state.busy = true; this.startTimer(); this.tick(); });
27
- this.#pi.on("message_start", async (e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx) && e.message.role === "assistant") { this.#state.liveTokens = 0; this.#counter.reset(); } });
28
- this.#pi.on("message_update", async (e, ctx) => { this.#currentCtx = ctx; const a = e.assistantMessageEvent; if (this.usable(ctx) && a && (a.type === "text_delta" || a.type === "thinking_delta")) this.#state.liveTokens += this.#counter.count(a.delta, a.type); });
29
- this.#pi.on("message_end", async (e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx) || e.message.role !== "assistant") return; this.#state.confirmTokens += e.message.usage?.output ?? this.#state.liveTokens; this.#state.liveTokens = 0; this.#counter.reset(); });
30
- this.#pi.on("tool_execution_start", async (_e, ctx) => { this.#currentCtx = ctx; if (this.usable(ctx)) { this.#state.currentWord = pickRandomWord(); this.#state.shimmerOrigin = Date.now(); } });
31
- this.#pi.on("agent_settled", async (_e, ctx) => { this.#currentCtx = ctx; if (!this.usable(ctx)) return; const hadPrompt = !!this.#state.startTime; const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0; this.#state.busy = false; this.#state.startTime = 0; this.#counter.reset(); this.stopTimer(); ctx.ui.setWorkingMessage(); this.applyIndicator(ctx); this.#state.activityMeter.reset(); this.#state.rateTracker.reset(); this.#state.lastActivityMeterUpdate = 0; if (this.#settings.features.doneMarker && hadPrompt) this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, { word: pickRawWord().past_tense, elapsedMs }); this.#currentCtx = null; });
32
- this.#pi.on("session_shutdown", async () => this.stopTimer());
33
- this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => entry.data ? new Text(`${theme.fg("text", "π")}${theme.fg("dim", ` ${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}`)}`) : undefined);
34
- this.#pi.registerCommand("topping-settings", { description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.", handler: async (_a, ctx) => this.showSettings(ctx) });
35
- }
36
- private usable(ctx: ExtensionContext): boolean { return !!ctx.hasUI && ctx.mode !== "print"; }
37
- private applyIndicator(ctx: ExtensionContext): void { ctx.ui.setWorkingIndicator(this.#settings.decorations.animatedSpinner ? undefined : { frames: [] }); }
38
- private stopTimer(): void { if (this.#state.timer) { clearInterval(this.#state.timer); this.#state.timer = null; } }
39
- private startTimer(): void { if (!this.#state.timer) this.#state.timer = setInterval(() => this.tick(), SHIMMER_INTERVAL); }
40
- private resetTurn(now: number): void { const s = this.#state; s.startTime = now; s.shimmerOrigin = now; s.currentWord = pickRandomWord(); s.confirmTokens = 0; s.liveTokens = 0; s.activityMeter.reset(); s.rateTracker.reset(); s.lastActivityMeterUpdate = 0; this.#counter.reset(); }
41
- private tick(): void { const ctx = this.#currentCtx, s = this.#state; if (!s.busy || !ctx) return; const now = Date.now(), total = s.confirmTokens + s.liveTokens; if (now - s.lastActivityMeterUpdate >= METER_INTERVAL_MS) { s.activityMeter.push(rateToLevel(s.rateTracker.sample(total, now))); s.lastActivityMeterUpdate = now; } const f = this.#settings.features, d = this.#settings.decorations; if (isFullyDefaultAppearance(f, d)) return ctx.ui.setWorkingMessage(); const word = f.substituteDefaultMessage ? s.currentWord : DEFAULT_WORKING_WORD; const styled = d.shimmer ? shimmerString(word, now - s.shimmerOrigin, ctx.ui.theme) : ctx.ui.theme.fg("text", word); const meter = d.tokenActivityMonitor ? s.activityMeter.render((l, c) => ActivityMeter.colorizeCell(l, c, ctx.ui.theme)) : ""; ctx.ui.setWorkingMessage(buildWorkingMessage(ctx.ui.theme, styled, meter, formatElapsed(now - s.startTime), formatTokens(total), f)); }
42
- private async showSettings(ctx: ExtensionCommandContext): Promise<void> { if (ctx.mode !== "tui") { ctx.ui.notify("/topping-settings requires TUI mode", "error"); return; } const before = this.#settings.decorations.animatedSpinner; const preview = new PreviewRenderer(ctx); const result = await showMenu<Record<string, boolean>>(ctx, { title: "Pi Topping: Settings", sections: buildMenuSections(this.#settings), hints: ["↑↓ move", "␣ toggle", "⏎ apply", "esc cancel"], preview: preview.render.bind(preview) }); if (!result.applied) return; this.#settings = applyMenuResult(this.#settings, result.values); saveSettings(this.#settings); if (before !== this.#settings.decorations.animatedSpinner) this.applyIndicator(ctx); this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection); ctx.ui.notify("Pi Topping settings saved", "info"); }
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
  }