@rol1n/srp-theme 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # srp-theme
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Incremental Pi terminal presentation enhancement: adds a `belowEditor` last-prompt preview widget and a real-time streaming TPS (tokens per second) status meter while keeping Pi's native header and footer intact.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@rol1n/srp-theme
9
+ ```
10
+
11
+ Also included in `@rol1n/pi-kit`.
12
+
13
+ ## Commands
14
+
15
+ ```text
16
+ /srp-theme on
17
+ /srp-theme off
18
+ ```
package/footer.ts ADDED
@@ -0,0 +1,193 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ // ============================ Prompt Preview Widget ============================
5
+
6
+ const POWERLINE_SEP_ANSI = "\x1b[38;5;244m";
7
+ const ANSI_RESET = "\x1b[0m";
8
+
9
+ /** Compact a multiline prompt into a single line, matching powerline's last-prompt behavior. */
10
+ export function compactPrompt(prompt: string | null | undefined): string {
11
+ // Custom-message runs (extension `sendMessage`, auto-continue, retries) have no user prompt.
12
+ if (typeof prompt !== "string") return "";
13
+ return prompt.replace(/\s+/g, " ").trim();
14
+ }
15
+
16
+ /**
17
+ * Render last prompt line using powerline's sep color (ANSI 244), displaying '↳ prompt'.
18
+ */
19
+ export function renderLastPromptLine(
20
+ lastUserPrompt: string | null | undefined,
21
+ width: number,
22
+ ): string[] {
23
+ const compact = compactPrompt(lastUserPrompt);
24
+ const prefix = ` ${POWERLINE_SEP_ANSI}↳${ANSI_RESET} `;
25
+ const availableWidth = width - visibleWidth(prefix);
26
+
27
+ if (!compact || availableWidth < 10) return [];
28
+
29
+ return [
30
+ truncateToWidth(
31
+ `${prefix}${POWERLINE_SEP_ANSI}${truncateToWidth(compact, availableWidth, "…")}${ANSI_RESET}`,
32
+ width,
33
+ "…",
34
+ ),
35
+ ];
36
+ }
37
+
38
+
39
+
40
+ // ============================ Incremental Time / Finished Widget ============================
41
+
42
+ // const WEEK_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
43
+ // type ClockVariant = "full" | "month_day" | "time_only";
44
+ type FinishedVariant = "full" | "compact" | "duration_only";
45
+
46
+ function formatDuration(ms: number): string {
47
+ if (ms < 0) return "0.0s";
48
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
49
+ if (ms < 3600_000) {
50
+ const totalSecs = Math.round(ms / 1000);
51
+ const mins = Math.floor(totalSecs / 60);
52
+ const secs = totalSecs % 60;
53
+ return `${mins}m${secs < 10 ? "0" : ""}${secs}s`;
54
+ }
55
+ const totalMins = Math.round(ms / 60000);
56
+ const hours = Math.floor(totalMins / 60);
57
+ const mins = totalMins % 60;
58
+ return `${hours}h${mins < 10 ? "0" : ""}${mins}m`;
59
+ }
60
+
61
+ /*
62
+ // 时间胶囊格式化已注释停用
63
+ function formatFooterClockVariant(date: Date, variant: ClockVariant): string {
64
+ const pad = (value: number) => value.toString().padStart(2, "0");
65
+ const hours = pad(date.getHours());
66
+ const minutes = pad(date.getMinutes());
67
+
68
+ if (variant === "time_only") return `[${hours}:${minutes}]`;
69
+
70
+ const month = pad(date.getMonth() + 1);
71
+ const dayOfMonth = pad(date.getDate());
72
+ if (variant === "month_day") return `[${month}-${dayOfMonth} ${hours}:${minutes}]`;
73
+
74
+ return `[${date.getFullYear()}-${month}-${dayOfMonth} ${WEEK_DAYS[date.getDay()]} ${hours}:${minutes}]`;
75
+ }
76
+ */
77
+
78
+ function formatLoopEndVariant(
79
+ finished: Date,
80
+ now: Date,
81
+ durationMs: number | null,
82
+ variant: FinishedVariant,
83
+ ): string {
84
+ const pad = (value: number) => value.toString().padStart(2, "0");
85
+ const hasDuration = typeof durationMs === "number" && durationMs >= 0;
86
+
87
+ if (variant === "duration_only") {
88
+ return hasDuration ? `X ${formatDuration(durationMs)}` : "X";
89
+ }
90
+
91
+ const isCrossYear = finished.getFullYear() !== now.getFullYear();
92
+ const isCrossDay =
93
+ finished.getMonth() !== now.getMonth() || finished.getDate() !== now.getDate();
94
+ const dateOrTime = isCrossYear
95
+ ? `${finished.getFullYear()}`
96
+ : isCrossDay
97
+ ? `${pad(finished.getMonth() + 1)}-${pad(finished.getDate())}`
98
+ : `${pad(finished.getHours())}:${pad(finished.getMinutes())}`;
99
+
100
+ const details = hasDuration ? `${formatDuration(durationMs)} ${dateOrTime}` : dateOrTime;
101
+ return `X ${details}`;
102
+ }
103
+
104
+ export function getLastLoopInfoFromSession(ctx: ExtensionContext): {
105
+ endTime: Date;
106
+ durationMs: number | null;
107
+ } | null {
108
+ try {
109
+ const entries = ctx.sessionManager?.getEntries?.() ?? [];
110
+ let assistantIndex = -1;
111
+ let endTime: Date | null = null;
112
+
113
+ for (let i = entries.length - 1; i >= 0; i--) {
114
+ const entry = entries[i] as any;
115
+ if (entry?.type !== "message" || entry?.message?.role !== "assistant" || !entry?.timestamp) continue;
116
+ const timestamp = new Date(entry.timestamp);
117
+ if (!Number.isNaN(timestamp.getTime())) {
118
+ assistantIndex = i;
119
+ endTime = timestamp;
120
+ break;
121
+ }
122
+ }
123
+
124
+ if (!endTime || assistantIndex < 0) return null;
125
+
126
+ let durationMs: number | null = null;
127
+ for (let i = assistantIndex - 1; i >= 0; i--) {
128
+ const entry = entries[i] as any;
129
+ if (entry?.type !== "message" || entry?.message?.role !== "user" || !entry?.timestamp) continue;
130
+ const startTime = new Date(entry.timestamp);
131
+ if (!Number.isNaN(startTime.getTime())) {
132
+ const duration = endTime.getTime() - startTime.getTime();
133
+ durationMs = duration >= 0 ? duration : null;
134
+ break;
135
+ }
136
+ }
137
+
138
+ return { endTime, durationMs };
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export function renderTimeAndFinishedLine(
145
+ lastLoopEndTime: Date | null,
146
+ lastLoopDurationMs: number | null,
147
+ width: number,
148
+ theme: Theme,
149
+ ): string[] {
150
+ if (!lastLoopEndTime) return [];
151
+
152
+ const now = new Date();
153
+ const candidates: string[] = [];
154
+
155
+ const fullFinished = formatLoopEndVariant(lastLoopEndTime, now, lastLoopDurationMs, "full");
156
+ const compactFinished = formatLoopEndVariant(lastLoopEndTime, now, lastLoopDurationMs, "compact");
157
+ const durationFinished = formatLoopEndVariant(lastLoopEndTime, now, lastLoopDurationMs, "duration_only");
158
+
159
+ candidates.push(fullFinished);
160
+ if (compactFinished !== fullFinished) {
161
+ candidates.push(compactFinished);
162
+ }
163
+ if (durationFinished && durationFinished !== compactFinished && durationFinished !== fullFinished) {
164
+ candidates.push(durationFinished);
165
+ }
166
+
167
+ /*
168
+ // 时间胶囊(已禁用/注释,仅保留 agent-loop 完成时间和用时):
169
+ if (lastLoopEndTime) {
170
+ candidates.push(
171
+ `${fullFinished} ${formatFooterClockVariant(now, "full")}`,
172
+ `${fullFinished} ${formatFooterClockVariant(now, "month_day")}`,
173
+ `${fullFinished} ${formatFooterClockVariant(now, "time_only")}`,
174
+ );
175
+ if (compactFinished !== fullFinished) {
176
+ candidates.push(`${compactFinished} ${formatFooterClockVariant(now, "time_only")}`);
177
+ }
178
+ if (durationFinished && durationFinished !== compactFinished && durationFinished !== fullFinished) {
179
+ candidates.push(`${durationFinished} ${formatFooterClockVariant(now, "time_only")}`);
180
+ }
181
+ } else {
182
+ candidates.push(
183
+ formatFooterClockVariant(now, "full"),
184
+ formatFooterClockVariant(now, "month_day"),
185
+ formatFooterClockVariant(now, "time_only"),
186
+ );
187
+ }
188
+ */
189
+
190
+ const content = candidates.find((candidate) => visibleWidth(candidate) <= width) ?? candidates.at(-1)!;
191
+ const line = truncateToWidth(content, width, "…");
192
+ return [theme.fg("dim", line)];
193
+ }
package/index.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * srp-theme — Prompt preview and TPS meter extension.
3
+ *
4
+ * Scheme A (Pure Incremental Mode):
5
+ * Leaves Pi's native Header and Footer untouched so official features
6
+ * (CH cache hit rate, W cache write, auto-compaction indicator, routed models, etc.)
7
+ * work out-of-the-box without maintenance drift.
8
+ *
9
+ * Adds two lightweight incremental UI enhancements:
10
+ * 1. belowEditor widget: Displays the last user prompt in powerline style (↳ <prompt>).
11
+ * 2. Status line item: Real-time streaming TPS meter and sparkline summary.
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
16
+ import { getLastLoopInfoFromSession, renderLastPromptLine, renderTimeAndFinishedLine } from "./footer.ts";
17
+ import { safeFallbackTheme, TpsMeter } from "./tps-meter.ts";
18
+
19
+ export { compactPrompt, renderLastPromptLine } from "./footer.ts";
20
+ export { TpsMeter } from "./tps-meter.ts";
21
+
22
+ export default function (pi: ExtensionAPI) {
23
+ let lastUserPrompt = "";
24
+ let footerEnabled = true;
25
+ let lastLoopEndTime: Date | null = null;
26
+ let lastLoopDurationMs: number | null = null;
27
+ let currentLoopStartMs = 0;
28
+ const tpsMeter = new TpsMeter();
29
+
30
+ const installFooter = (ctx: ExtensionContext): void => {
31
+ // Keep Pi's native footer active; this widget is purely incremental.
32
+ ctx.ui.setFooter(undefined);
33
+ ctx.ui.setWidget(
34
+ "srp-footer",
35
+ (_tui, theme) => {
36
+ return {
37
+ invalidate() {},
38
+ dispose() {},
39
+ render(width: number): string[] {
40
+ return [
41
+ ...renderLastPromptLine(lastUserPrompt, width),
42
+ ...renderTimeAndFinishedLine(lastLoopEndTime, lastLoopDurationMs, width, theme),
43
+ ];
44
+ },
45
+ };
46
+ },
47
+ { placement: "belowEditor" },
48
+ );
49
+ };
50
+
51
+ const removeFooter = (ctx: ExtensionContext): void => {
52
+ ctx.ui.setWidget("srp-footer", undefined);
53
+ ctx.ui.setFooter(undefined);
54
+ };
55
+
56
+ pi.on("session_start", (_event, ctx) => {
57
+ lastUserPrompt = "";
58
+ currentLoopStartMs = 0;
59
+ const info = getLastLoopInfoFromSession(ctx);
60
+ lastLoopEndTime = info?.endTime ?? null;
61
+ lastLoopDurationMs = info?.durationMs ?? null;
62
+ footerEnabled = true;
63
+ tpsMeter.enabled = true;
64
+ tpsMeter.reset(ctx);
65
+
66
+ if (ctx.mode === "tui") {
67
+ // Restore native header & footer
68
+ ctx.ui.setHeader(undefined);
69
+ ctx.ui.setFooter(undefined);
70
+ removeFooter(ctx);
71
+ }
72
+ });
73
+
74
+ // Schedule resources_discover after session_start to position widget below powerline widgets
75
+ pi.on("resources_discover", (_event, ctx) => {
76
+ if (footerEnabled && ctx.mode === "tui") {
77
+ installFooter(ctx);
78
+ }
79
+ });
80
+
81
+ // event.prompt is expanded user input. It is absent for custom-message runs
82
+ // (messageType === "custom"), e.g. extension-triggered auto-continue; keep the previous prompt then.
83
+ pi.on("before_agent_start", (event) => {
84
+ if (event.messageType === "user" && typeof event.prompt === "string") {
85
+ lastUserPrompt = event.prompt;
86
+ }
87
+ currentLoopStartMs = Date.now();
88
+ });
89
+
90
+ // TPS monitoring and lifecycle hooks
91
+ pi.on("message_start", (event, ctx) => {
92
+ if (event.message.role !== "assistant") return;
93
+ tpsMeter.onMessageStart(ctx);
94
+ });
95
+
96
+ pi.on("message_update", (event) => {
97
+ if (event.message.role !== "assistant") return;
98
+ tpsMeter.onMessageUpdate(event.assistantMessageEvent as { type: string; delta?: unknown });
99
+ });
100
+
101
+ pi.on("message_end", (event, ctx) => {
102
+ if (event.message.role !== "assistant") return;
103
+ const realOut = (event.message as { usage?: { output?: number } })?.usage?.output;
104
+ tpsMeter.onMessageEnd(realOut, ctx);
105
+ });
106
+
107
+ pi.on("tool_execution_start", (_event, ctx) => {
108
+ tpsMeter.onToolStart(ctx);
109
+ });
110
+
111
+ pi.on("tool_call", (_event, ctx) => {
112
+ tpsMeter.onToolStart(ctx);
113
+ });
114
+
115
+ pi.on("turn_end", (_event, ctx) => {
116
+ tpsMeter.onTurnEnd(ctx);
117
+ });
118
+
119
+ pi.on("agent_end", (_event, ctx) => {
120
+ lastLoopEndTime = new Date();
121
+ if (currentLoopStartMs > 0) {
122
+ lastLoopDurationMs = Math.max(0, Date.now() - currentLoopStartMs);
123
+ }
124
+ tpsMeter.onAgentEnd(ctx);
125
+ });
126
+
127
+ pi.on("agent_settled", (_event, ctx) => {
128
+ lastLoopEndTime = new Date();
129
+ if (currentLoopStartMs > 0) {
130
+ lastLoopDurationMs = Math.max(0, Date.now() - currentLoopStartMs);
131
+ }
132
+ tpsMeter.onAgentSettled(ctx);
133
+ });
134
+
135
+ pi.registerCommand("srp-theme", {
136
+ description: "Toggle SRP prompt widget and TPS meter on/off",
137
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
138
+ const candidates: AutocompleteItem[] = [
139
+ { value: "on", label: "on", description: "Enable prompt preview and TPS meter" },
140
+ { value: "off", label: "off", description: "Disable prompt preview and TPS meter" },
141
+ ];
142
+ const filtered = candidates.filter((item) => item.value.startsWith(prefix.trimStart()));
143
+ return filtered.length > 0 ? filtered : null;
144
+ },
145
+ handler: async (args, ctx) => {
146
+ const state = args.trim().toLowerCase();
147
+ if (state !== "on" && state !== "off") {
148
+ ctx.ui.notify("Usage: /srp-theme on|off", "info");
149
+ return;
150
+ }
151
+
152
+ const enabled = state === "on";
153
+ footerEnabled = enabled;
154
+ tpsMeter.enabled = enabled;
155
+
156
+ if (ctx.mode === "tui") {
157
+ ctx.ui.setFooter(undefined);
158
+ if (enabled) installFooter(ctx);
159
+ else removeFooter(ctx);
160
+ }
161
+
162
+ if (enabled) {
163
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
164
+ ctx.ui.setStatus("tps", tpsMeter.renderFinal(theme));
165
+ } else {
166
+ tpsMeter.stopTick();
167
+ ctx.ui.setStatus("tps", undefined);
168
+ }
169
+ ctx.ui.notify(`srp-theme: ${enabled ? "Enabled" : "Disabled"} prompt widget and TPS`, "info");
170
+ },
171
+ });
172
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@rol1n/srp-theme",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "srp-theme extension from pi-kit",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/RolinShmily/pi-kit.git",
12
+ "directory": "srp-theme"
13
+ },
14
+ "homepage": "https://github.com/RolinShmily/pi-kit#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/RolinShmily/pi-kit/issues"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-ai": "*",
26
+ "@earendil-works/pi-agent-core": "*",
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*",
29
+ "typebox": "*"
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ],
35
+ "skills": [],
36
+ "prompts": [],
37
+ "themes": []
38
+ }
39
+ }
package/tps-meter.ts ADDED
@@ -0,0 +1,441 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ export class TpsMeter {
5
+ // Constants
6
+ static readonly WINDOW_SIZE = 60;
7
+ static readonly WINDOW_MS = 60_000;
8
+ static readonly STREAM_INTERVAL_MS = 200;
9
+ static readonly SPARK_LEN = 12;
10
+ static readonly ALLTIME_CAP = 500;
11
+ static readonly FAST = 50;
12
+ static readonly MED = 20;
13
+
14
+ // Render glyphs
15
+ static readonly BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
16
+ static readonly HBLOCKS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
17
+ static readonly GAUGE_LEN = 11;
18
+ static readonly GAUGE_FLOOR = 40;
19
+ static readonly TRACK = "·";
20
+ static readonly SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
+
22
+ // State variables
23
+ private streamStartMs = 0;
24
+ private firstTokenMs = 0;
25
+ private streamChars = 0;
26
+ private streamTokens = 0;
27
+ private tickTimer: ReturnType<typeof setInterval> | null = null;
28
+ private streaming = false;
29
+
30
+ // 60-second rolling window (ring buffer: [tps, timestamp])
31
+ private readonly winBuf = new Float64Array(TpsMeter.WINDOW_SIZE * 2);
32
+ private winLen = 0;
33
+ private winHead = 0;
34
+
35
+ // Session-wide sampling (ring buffer: [tps])
36
+ private readonly atBuf = new Float64Array(TpsMeter.ALLTIME_CAP);
37
+ private atLen = 0;
38
+ private atHead = 0;
39
+ private atSum = 0;
40
+
41
+ // Sparkline history for last 12 messages
42
+ private readonly sparkBuf = new Float64Array(TpsMeter.SPARK_LEN);
43
+ private sparkLen = 0;
44
+ private sparkHead = 0;
45
+ private sparkMax = 1;
46
+ private sparkCache = "";
47
+ private sparkDirty = true;
48
+ private sparkTheme: Theme | null = null;
49
+ private spinI = 0;
50
+ private lastStatusText: string | undefined = undefined;
51
+
52
+ public enabled = true;
53
+
54
+ private applyStatus(ctx: ExtensionContext, text: string | undefined): void {
55
+ if (this.lastStatusText === text) return;
56
+ this.lastStatusText = text;
57
+ if (ctx.ui?.setStatus) {
58
+ ctx.ui.setStatus("tps", text);
59
+ }
60
+ }
61
+
62
+ reset(ctx?: ExtensionContext): void {
63
+ this.streaming = false;
64
+ this.stopTick();
65
+ this.streamStartMs = 0;
66
+ this.firstTokenMs = 0;
67
+ this.streamChars = 0;
68
+ this.streamTokens = 0;
69
+ this.winLen = 0;
70
+ this.winHead = 0;
71
+ this.atLen = 0;
72
+ this.atHead = 0;
73
+ this.atSum = 0;
74
+ this.sparkLen = 0;
75
+ this.sparkHead = 0;
76
+ this.sparkMax = 1;
77
+ this.sparkCache = "";
78
+ this.sparkDirty = true;
79
+ this.sparkTheme = null;
80
+ this.spinI = 0;
81
+ this.lastStatusText = undefined;
82
+ if (ctx) {
83
+ if (this.enabled) {
84
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
85
+ this.applyStatus(ctx, this.renderFinal(theme));
86
+ } else {
87
+ this.applyStatus(ctx, undefined);
88
+ }
89
+ }
90
+ }
91
+
92
+ private now(): number {
93
+ return Date.now();
94
+ }
95
+
96
+ private tokEst(ch: number): number {
97
+ return (ch >>> 2) + ((ch & 3) > 0 ? 1 : 0);
98
+ }
99
+
100
+ private winPush(tps: number, ms: number): void {
101
+ const b = this.winHead * 2;
102
+ this.winBuf[b] = tps;
103
+ this.winBuf[b + 1] = ms;
104
+ this.winHead = (this.winHead + 1) % TpsMeter.WINDOW_SIZE;
105
+ if (this.winLen < TpsMeter.WINDOW_SIZE) this.winLen++;
106
+ }
107
+
108
+ private atPush(tps: number): void {
109
+ this.atSum += tps;
110
+ if (this.atLen >= TpsMeter.ALLTIME_CAP) this.atSum -= this.atBuf[this.atHead];
111
+ this.atBuf[this.atHead] = tps;
112
+ this.atHead = (this.atHead + 1) % TpsMeter.ALLTIME_CAP;
113
+ if (this.atLen < TpsMeter.ALLTIME_CAP) this.atLen++;
114
+ }
115
+
116
+ private sparkPush(tps: number): void {
117
+ this.sparkBuf[this.sparkHead] = tps;
118
+ this.sparkHead = (this.sparkHead + 1) % TpsMeter.SPARK_LEN;
119
+ if (this.sparkLen < TpsMeter.SPARK_LEN) this.sparkLen++;
120
+ if (tps > this.sparkMax) this.sparkMax = tps;
121
+ if (this.sparkMax > 10) this.sparkMax *= 0.99;
122
+ this.sparkDirty = true;
123
+ }
124
+
125
+ winAvg(): number {
126
+ if (this.winLen === 0) return 0;
127
+ const cutoff = this.now() - TpsMeter.WINDOW_MS;
128
+ let sum = 0;
129
+ let n = 0;
130
+ const oldest = this.winLen < TpsMeter.WINDOW_SIZE ? 0 : this.winHead;
131
+ for (let i = 0; i < this.winLen; i++) {
132
+ const idx = (oldest + i) % TpsMeter.WINDOW_SIZE;
133
+ const b = idx * 2;
134
+ if (this.winBuf[b + 1] < cutoff) continue;
135
+ sum += this.winBuf[b];
136
+ n++;
137
+ }
138
+ return n === 0 ? 0 : sum / n;
139
+ }
140
+
141
+ atMean(): number {
142
+ return this.atLen === 0 ? 0 : this.atSum / this.atLen;
143
+ }
144
+
145
+ atP95(): number {
146
+ if (this.atLen === 0) return 0;
147
+ const tmp = new Float64Array(this.atLen);
148
+ const oldest = this.atLen < TpsMeter.ALLTIME_CAP ? 0 : this.atHead;
149
+ for (let i = 0; i < this.atLen; i++) tmp[i] = this.atBuf[(oldest + i) % TpsMeter.ALLTIME_CAP];
150
+ // Insertion sort
151
+ for (let i = 1; i < tmp.length; i++) {
152
+ const v = tmp[i];
153
+ let j = i - 1;
154
+ while (j >= 0 && tmp[j] > v) {
155
+ tmp[j + 1] = tmp[j];
156
+ j--;
157
+ }
158
+ tmp[j + 1] = v;
159
+ }
160
+ return tmp[Math.ceil(tmp.length * 0.95) - 1] || 0;
161
+ }
162
+
163
+ private fmt(v: number): string {
164
+ if (v < 10) return v.toFixed(1);
165
+ if (v < 100) return v.toFixed(0);
166
+ return `${Math.round(v)}`;
167
+ }
168
+
169
+ private speedColor(tps: number, text: string, theme: Theme): string {
170
+ if (tps >= TpsMeter.FAST) return theme.fg("success", text);
171
+ if (tps >= TpsMeter.MED) return theme.fg("warning", text);
172
+ if (tps === 0) return theme.fg("dim", text);
173
+ return theme.fg("error", text);
174
+ }
175
+
176
+ private spin(): string {
177
+ const s = TpsMeter.SPIN[this.spinI];
178
+ this.spinI = (this.spinI + 1) % TpsMeter.SPIN.length;
179
+ return s;
180
+ }
181
+
182
+ sparkline(theme: Theme, len = TpsMeter.SPARK_LEN): string {
183
+ len = Math.max(1, Math.min(TpsMeter.SPARK_LEN, Math.floor(len)));
184
+
185
+ if (this.sparkLen === 0) {
186
+ return theme.fg("dim", TpsMeter.TRACK.repeat(len));
187
+ }
188
+
189
+ const available = Math.min(this.sparkLen, len);
190
+ const vals = new Float64Array(available);
191
+ for (let i = 0; i < available; i++) {
192
+ const idx =
193
+ (this.sparkHead - available + i + TpsMeter.SPARK_LEN) %
194
+ TpsMeter.SPARK_LEN;
195
+ vals[i] = this.sparkBuf[idx];
196
+ }
197
+
198
+ let mn = Infinity;
199
+ let mx = 0;
200
+ for (let i = 0; i < available; i++) {
201
+ const v = vals[i];
202
+ if (v < mn) mn = v;
203
+ if (v > mx) mx = v;
204
+ }
205
+ const range = mx - mn;
206
+ const pad = len - available;
207
+ let result = pad > 0 ? theme.fg("dim", TpsMeter.TRACK.repeat(pad)) : "";
208
+
209
+ for (let i = 0; i < available; i++) {
210
+ const v = vals[i];
211
+ const norm =
212
+ range < 1e-6
213
+ ? mx > 0
214
+ ? 4
215
+ : 0
216
+ : Math.min(7, Math.max(0, Math.round(((v - mn) / range) * 7)));
217
+ const ch = TpsMeter.BLOCKS[norm];
218
+ result += this.speedColor(v, ch, theme);
219
+ }
220
+ return result;
221
+ }
222
+
223
+ gauge(tps: number, theme: Theme, gaugeLen = TpsMeter.GAUGE_LEN): string {
224
+ gaugeLen = Math.max(1, Math.floor(gaugeLen));
225
+ const scale = Math.max(this.sparkMax, TpsMeter.GAUGE_FLOOR);
226
+ let frac = scale > 0 ? tps / scale : 0;
227
+ if (frac < 0) frac = 0;
228
+ if (frac > 1) frac = 1;
229
+
230
+ const eighths = Math.round(frac * gaugeLen * 8);
231
+ const full = (eighths / 8) | 0;
232
+ const rem = eighths % 8;
233
+
234
+ let fill = "█".repeat(Math.min(gaugeLen, full));
235
+ let used = Math.min(gaugeLen, full);
236
+ if (full < gaugeLen && rem > 0) {
237
+ fill += TpsMeter.HBLOCKS[rem];
238
+ used++;
239
+ }
240
+ const track = TpsMeter.TRACK.repeat(Math.max(0, gaugeLen - used));
241
+
242
+ return (
243
+ theme.fg("dim", "▕") +
244
+ this.speedColor(tps, fill, theme) +
245
+ theme.fg("dim", track + "▏")
246
+ );
247
+ }
248
+
249
+ isStreaming(): boolean {
250
+ return this.streaming;
251
+ }
252
+
253
+ renderLive(theme: Theme, maxBudget?: number): string {
254
+ const ref = this.firstTokenMs > 0 ? this.firstTokenMs : this.streamStartMs;
255
+ const elapsed = (this.now() - ref) / 1000;
256
+ const tps = elapsed > 0.3 ? this.streamTokens / elapsed : 0;
257
+
258
+ const s = theme.fg("accent", this.spin());
259
+ const num = this.speedColor(tps, this.fmt(tps), theme);
260
+ const unit = theme.fg("dim", "tps");
261
+ const numUnit = `${num} ${unit}`;
262
+ const minCompact = `${s} ${numUnit}`;
263
+ const bareNumUnit = numUnit;
264
+
265
+ if (maxBudget == null) {
266
+ const g = this.gauge(tps, theme, TpsMeter.GAUGE_LEN);
267
+ return `${s} ${g} ${numUnit}`;
268
+ }
269
+
270
+ if (maxBudget < visibleWidth(bareNumUnit)) {
271
+ return "";
272
+ }
273
+
274
+ // Attempt full mode with gauge
275
+ const fixedOverhead = visibleWidth(s) + 1 + 2 + 1 + visibleWidth(numUnit);
276
+ const availableGaugeLen = maxBudget - fixedOverhead;
277
+
278
+ if (availableGaugeLen >= 3) {
279
+ const targetGaugeLen = Math.min(TpsMeter.GAUGE_LEN, availableGaugeLen);
280
+ const g = this.gauge(tps, theme, targetGaugeLen);
281
+ const fullStr = `${s} ${g} ${numUnit}`;
282
+ if (visibleWidth(fullStr) <= maxBudget) {
283
+ return fullStr;
284
+ }
285
+ }
286
+
287
+ if (maxBudget >= visibleWidth(minCompact)) {
288
+ return minCompact;
289
+ }
290
+
291
+ return bareNumUnit;
292
+ }
293
+
294
+ renderFinal(theme: Theme, maxBudget?: number): string {
295
+ const avg = this.winAvg();
296
+ const mu = this.atMean();
297
+ const p95 = this.atP95();
298
+
299
+ const a = this.speedColor(avg, this.fmt(avg), theme);
300
+ const label = theme.fg("dim", "tps");
301
+ const numUnit = `${a} ${label}`;
302
+
303
+ if (maxBudget == null) {
304
+ const sp = this.sparkline(theme, TpsMeter.SPARK_LEN);
305
+ const sep = theme.fg("dim", "·");
306
+ const m = `${theme.fg("dim", "μ")} ${this.speedColor(mu, this.fmt(mu), theme)}`;
307
+ const p = `${theme.fg("dim", "p95")} ${this.speedColor(p95, this.fmt(p95), theme)}`;
308
+ return `${sp} ${numUnit} ${sep} ${m} ${sep} ${p}`;
309
+ }
310
+
311
+ if (maxBudget < visibleWidth(numUnit)) {
312
+ return "";
313
+ }
314
+
315
+ // 1. Attempt full mode (sparkline 12 + numUnit + μ + p95)
316
+ const spFull = this.sparkline(theme, TpsMeter.SPARK_LEN);
317
+ const sep = theme.fg("dim", "·");
318
+ const m = `${theme.fg("dim", "μ")} ${this.speedColor(mu, this.fmt(mu), theme)}`;
319
+ const p = `${theme.fg("dim", "p95")} ${this.speedColor(p95, this.fmt(p95), theme)}`;
320
+ const l3 = `${spFull} ${numUnit} ${sep} ${m} ${sep} ${p}`;
321
+ if (visibleWidth(l3) <= maxBudget) {
322
+ return l3;
323
+ }
324
+
325
+ // 2. Dynamic sparkline (length 12 down to 2)
326
+ const fixedOverhead = 1 + visibleWidth(numUnit);
327
+ const availableSparkLen = maxBudget - fixedOverhead;
328
+
329
+ if (availableSparkLen >= 2) {
330
+ const targetSparkLen = Math.min(TpsMeter.SPARK_LEN, availableSparkLen);
331
+ const sp = this.sparkline(theme, targetSparkLen);
332
+ const l2 = `${sp} ${numUnit}`;
333
+ if (visibleWidth(l2) <= maxBudget) {
334
+ return l2;
335
+ }
336
+ }
337
+
338
+ // 3. Bare numUnit fallback
339
+ return numUnit;
340
+ }
341
+
342
+ renderAdaptive(theme: Theme, maxBudget: number): string {
343
+ if (!this.enabled) return "";
344
+ if (this.streaming) {
345
+ return this.renderLive(theme, maxBudget);
346
+ }
347
+ return this.renderFinal(theme, maxBudget);
348
+ }
349
+
350
+ private startTick(ctx: ExtensionContext): void {
351
+ if (this.tickTimer) return;
352
+ this.tickTimer = setInterval(() => {
353
+ if (!this.streaming || !this.enabled) {
354
+ this.stopTick(ctx);
355
+ return;
356
+ }
357
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
358
+ this.applyStatus(ctx, this.renderLive(theme));
359
+ }, TpsMeter.STREAM_INTERVAL_MS);
360
+ (this.tickTimer as any)?.unref?.();
361
+ }
362
+
363
+ stopTick(ctx?: ExtensionContext): void {
364
+ this.streaming = false;
365
+ if (this.tickTimer) {
366
+ clearInterval(this.tickTimer);
367
+ this.tickTimer = null;
368
+ }
369
+ if (ctx && this.enabled) {
370
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
371
+ this.applyStatus(ctx, this.renderFinal(theme));
372
+ }
373
+ }
374
+
375
+ onMessageStart(ctx: ExtensionContext): void {
376
+ if (!this.enabled) return;
377
+ this.streamStartMs = this.now();
378
+ this.firstTokenMs = 0;
379
+ this.streamChars = 0;
380
+ this.streamTokens = 0;
381
+ this.streaming = true;
382
+ this.spinI = 0;
383
+ this.startTick(ctx);
384
+ }
385
+
386
+ onMessageUpdate(evt?: { type: string; delta?: unknown }): void {
387
+ if (!this.enabled || !evt) return;
388
+ if (evt.type === "text_delta" || evt.type === "thinking_delta") {
389
+ const d = evt.delta as string;
390
+ if (!d) return;
391
+ if (this.firstTokenMs === 0) this.firstTokenMs = this.now();
392
+ this.streamChars += d.length;
393
+ this.streamTokens = this.tokEst(this.streamChars);
394
+ }
395
+ }
396
+
397
+ onMessageEnd(usageOutput: number | undefined, ctx: ExtensionContext): void {
398
+ if (!this.enabled) return;
399
+ this.streaming = false;
400
+ this.stopTick();
401
+
402
+ const realOut = usageOutput;
403
+ const tokens =
404
+ typeof realOut === "number" && realOut > 0 ? realOut : this.streamTokens;
405
+
406
+ const ref = this.firstTokenMs > 0 ? this.firstTokenMs : this.streamStartMs;
407
+ const elapsed = (this.now() - ref) / 1000;
408
+ if (elapsed >= 0.1 && tokens > 0) {
409
+ const tps = tokens / elapsed;
410
+ this.winPush(tps, this.now());
411
+ this.atPush(tps);
412
+ this.sparkPush(tps);
413
+ }
414
+
415
+ const theme = ctx.ui?.theme ?? safeFallbackTheme;
416
+ const txt = this.renderFinal(theme);
417
+ this.applyStatus(ctx, txt || undefined);
418
+ }
419
+
420
+ onToolStart(ctx: ExtensionContext): void {
421
+ this.stopTick(ctx);
422
+ }
423
+
424
+ onTurnEnd(ctx?: ExtensionContext): void {
425
+ this.stopTick(ctx);
426
+ }
427
+
428
+ onAgentEnd(ctx?: ExtensionContext): void {
429
+ this.stopTick(ctx);
430
+ }
431
+
432
+ onAgentSettled(ctx?: ExtensionContext): void {
433
+ this.stopTick(ctx);
434
+ }
435
+ }
436
+
437
+ export const safeFallbackTheme: Theme = {
438
+ fg: (_color: string, text: string) => text,
439
+ bg: (_color: string, text: string) => text,
440
+ bold: (text: string) => text,
441
+ } as unknown as Theme;