token-stats-timer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/index.ts +102 -0
- package/package.json +20 -0
- package/run-timer.ts +181 -0
- package/token-stats.ts +1848 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# token-stats-timer
|
|
2
|
+
|
|
3
|
+
`@carlosgtrz/pi-run-timer` 与 `@liziy/token-stats` 的合并插件。
|
|
4
|
+
|
|
5
|
+
一个扩展、一个 footer,同时提供 run 计时与 token 用量/配额监控。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
Footer 上行(左对齐):
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
● run 01:23 · prev 00:40 · max 03:12 (Review README…) | ↑12k ↓3.4k CH87% ⚡77.7 t/s 5.3%/1.0M | 5h: 87% W: 92% ⏱ 2h 15m
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- `● run 01:23` —— 当前 run 已耗时(从空闲后的第一个 `agent_start` 到 `agent_settled`,含重试/压缩恢复/排队提示)
|
|
16
|
+
- `prev` / `max` —— 上一次 run 耗时 / 本会话分支内最长 run 耗时(带 15 字 prompt 预览)
|
|
17
|
+
- `↑ ↓ Σ CH` —— 累计输入 / 输出 / 总量 / 缓存命中率
|
|
18
|
+
- `⚡` —— 实时速率(2s rolling window,无流时回落到平均速率)
|
|
19
|
+
- 上下文占用(样式可配)
|
|
20
|
+
- `5h: W: ⏱` —— 套餐剩余(MiniMax / GLM / Kimi / DeepSeek 内置套餐,需在 /stats 里为当前 provider 启用)
|
|
21
|
+
|
|
22
|
+
Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
23
|
+
|
|
24
|
+
## 命令
|
|
25
|
+
|
|
26
|
+
- `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐)
|
|
27
|
+
- `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` —— 统计查询
|
|
28
|
+
- `/stats config` —— 显示样式 / 显示内容(含「计时器」开关)/ 配额刷新时间
|
|
29
|
+
|
|
30
|
+
## 与两个原包的兼容性
|
|
31
|
+
|
|
32
|
+
- 显示/套餐配置沿用 `~/.pi/agent/extensions/token-stats/`(原 token-stats 的配置直接生效)
|
|
33
|
+
- 统计日志沿用 `~/.pi/agent/extensions/token-stats-logs/`(历史数据 `/stats` 可直接查询)
|
|
34
|
+
- run 计时状态沿用 session 内 `run-timer-state` 自定义条目(`/reload` 后自动恢复,兼容旧 `turn-timer-state`)
|
|
35
|
+
|
|
36
|
+
## 安装(替换两个原包)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi remove npm:@carlosgtrz/pi-run-timer
|
|
40
|
+
pi remove npm:@liziy/token-stats
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
本目录已在 `~/.pi/agent/extensions/` 下,自动发现,`/reload` 或重启 pi 生效。
|
package/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// run-token-stats —— 合并 @carlosgtrz/pi-run-timer + @liziy/token-stats 的单一插件
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// Footer 上行:run 计时(● run 01:23 · prev 00:40 · max 03:12)+ token 指标
|
|
4
|
+
// (输入/输出/总量、缓存命中、速率、上下文、套餐配额),右侧模型名
|
|
5
|
+
// Footer 下行:cwd + git 分支 + 其他扩展状态
|
|
6
|
+
//
|
|
7
|
+
// 命令:/stats [day [date] | hour [date] | week | month [YYYY-MM] | config]
|
|
8
|
+
// 无参进入套餐配置
|
|
9
|
+
//
|
|
10
|
+
// 模块划分:
|
|
11
|
+
// run-timer.ts —— run 计时状态机 + session 持久化(原 pi-run-timer)
|
|
12
|
+
// token-stats.ts —— token 统计 + 套餐配额 + JSONL 日志 + /stats(原 token-stats)
|
|
13
|
+
//
|
|
14
|
+
// 与两个原包的兼容性:
|
|
15
|
+
// - 配置沿用 ~/.pi/agent/extensions/token-stats/{config.json,display-config.json}
|
|
16
|
+
// - 日志沿用 ~/.pi/agent/extensions/token-stats-logs/
|
|
17
|
+
// - 计时状态沿用 session 内 "run-timer-state" 自定义条目
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
21
|
+
import { createRunTimer } from "./run-timer.ts";
|
|
22
|
+
import { createTokenStats, formatUserPath, type SharedState } from "./token-stats.ts";
|
|
23
|
+
|
|
24
|
+
const shared: SharedState = {
|
|
25
|
+
sessionActive: false,
|
|
26
|
+
requestRender: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default function runTokenStatsExtension(pi: ExtensionAPI) {
|
|
30
|
+
const stats = createTokenStats(pi, shared);
|
|
31
|
+
const timer = createRunTimer(pi, shared);
|
|
32
|
+
|
|
33
|
+
pi.on("session_start", (_event, ctx) => {
|
|
34
|
+
shared.sessionActive = true;
|
|
35
|
+
|
|
36
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
37
|
+
const render = () => tui.requestRender();
|
|
38
|
+
shared.requestRender = render;
|
|
39
|
+
const unsub = footerData.onBranchChange(render);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
dispose() {
|
|
43
|
+
unsub();
|
|
44
|
+
if (shared.requestRender === render) shared.requestRender = null;
|
|
45
|
+
},
|
|
46
|
+
invalidate() {},
|
|
47
|
+
render(width: number): string[] {
|
|
48
|
+
// session 替换后旧 footer 可能仍被 TUI 渲染,此时 ctx 已失效,直接返回空
|
|
49
|
+
if (!shared.sessionActive) return [];
|
|
50
|
+
|
|
51
|
+
// ── 上行:计时 + 指标左对齐,模型名右对齐 ──
|
|
52
|
+
// 计时器放在指标行末尾(紧邻右侧模型名)
|
|
53
|
+
const leftParts: string[] = [];
|
|
54
|
+
leftParts.push(...stats.getMetricParts(theme, ctx));
|
|
55
|
+
if (stats.isTimerEnabled()) {
|
|
56
|
+
leftParts.push(timer.getStatusText(theme));
|
|
57
|
+
}
|
|
58
|
+
const left = leftParts.join(" | ");
|
|
59
|
+
|
|
60
|
+
const modelName = ctx.model?.id || "";
|
|
61
|
+
const provider = ctx.model?.provider || "";
|
|
62
|
+
const level = ctx.thinkingLevel || "off";
|
|
63
|
+
const rightSide = (provider ? `(${provider}) ${modelName}` : modelName) + ` · ${level}`;
|
|
64
|
+
|
|
65
|
+
const leftWidth = visibleWidth(left);
|
|
66
|
+
const rightWidth = visibleWidth(rightSide);
|
|
67
|
+
const topLine = leftWidth + rightWidth <= width
|
|
68
|
+
? left + " ".repeat(width - leftWidth - rightWidth) + rightSide
|
|
69
|
+
: leftWidth <= width
|
|
70
|
+
? left + " ".repeat(width - leftWidth) + truncateToWidth(rightSide, Math.max(0, width - leftWidth), "")
|
|
71
|
+
: truncateToWidth(left, width);
|
|
72
|
+
|
|
73
|
+
// ── 下行:cwd + git 分支 + 其他扩展状态 ────
|
|
74
|
+
const cwd = formatUserPath(ctx.cwd || "");
|
|
75
|
+
const branch = footerData.getGitBranch();
|
|
76
|
+
const cwdPart = branch ? `${cwd} (${branch})` : cwd;
|
|
77
|
+
|
|
78
|
+
const statuses = footerData.getExtensionStatuses();
|
|
79
|
+
const otherStatuses = Array.from(statuses.entries())
|
|
80
|
+
.filter(([k]) => k !== "token-stats-webui")
|
|
81
|
+
.map(([, v]) => v as string);
|
|
82
|
+
|
|
83
|
+
const bottomParts: string[] = [theme.fg("dim", cwdPart)];
|
|
84
|
+
if (otherStatuses.length > 0) {
|
|
85
|
+
bottomParts.push(theme.fg("dim", "│"));
|
|
86
|
+
bottomParts.push(...otherStatuses);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
truncateToWidth(topLine, width),
|
|
91
|
+
truncateToWidth(bottomParts.join(" "), width),
|
|
92
|
+
];
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
99
|
+
shared.sessionActive = false;
|
|
100
|
+
shared.requestRender = null;
|
|
101
|
+
});
|
|
102
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "token-stats-timer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "pi extension to display token usage and time spend.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-extension"
|
|
7
|
+
],
|
|
8
|
+
"license": "ISC",
|
|
9
|
+
"author": "",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "index.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"run-timer.ts",
|
|
14
|
+
"token-stats.ts",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/run-timer.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// run-token-stats / run-timer 模块
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// 由 @carlosgtrz/pi-run-timer 0.2.0 移植而来,与 @liziy/token-stats 合并为一个插件。
|
|
4
|
+
// 原实现通过 ctx.ui.setStatus 注入 footer;合并后改为提供 getStatusText(),
|
|
5
|
+
// 由 index.ts 把计时文本拼进统一 footer 的指标行。
|
|
6
|
+
//
|
|
7
|
+
// 显示:当前 run 已耗时 / 上一次 run 耗时 / 会话分支内最长 run 耗时(带 prompt 预览)
|
|
8
|
+
// 一次 run = 从空闲后的第一个 agent_start 到 agent_settled 的连续忙碌期,
|
|
9
|
+
// 包含重试、压缩恢复以及排队的 steering/follow-up 提示。
|
|
10
|
+
//
|
|
11
|
+
// 状态通过 pi.appendEntry 持久化到 session,/reload 后自动恢复。
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
Theme,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { SharedState } from "./token-stats.ts";
|
|
19
|
+
|
|
20
|
+
const STATE_TYPE = "run-timer-state";
|
|
21
|
+
const LEGACY_STATE_TYPE = "turn-timer-state";
|
|
22
|
+
|
|
23
|
+
type TimerState = {
|
|
24
|
+
currentRunStartMs?: number;
|
|
25
|
+
previousRunDurationMs?: number;
|
|
26
|
+
longestRunDurationMs?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function formatDuration(ms: number): string {
|
|
30
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
31
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
32
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
33
|
+
const seconds = totalSeconds % 60;
|
|
34
|
+
|
|
35
|
+
if (hours > 0) {
|
|
36
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
37
|
+
}
|
|
38
|
+
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isTimerState(value: unknown): value is TimerState {
|
|
42
|
+
if (!value || typeof value !== "object") return false;
|
|
43
|
+
const state = value as TimerState;
|
|
44
|
+
return (
|
|
45
|
+
(state.currentRunStartMs === undefined || typeof state.currentRunStartMs === "number") &&
|
|
46
|
+
(state.previousRunDurationMs === undefined || typeof state.previousRunDurationMs === "number") &&
|
|
47
|
+
(state.longestRunDurationMs === undefined || typeof state.longestRunDurationMs === "number")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RunTimerHandle {
|
|
52
|
+
/** 计时段文本,例如 "● run 01:23 · prev 00:40 · max 03:12 (Review README…)" */
|
|
53
|
+
getStatusText(theme: Theme): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createRunTimer(
|
|
57
|
+
pi: ExtensionAPI,
|
|
58
|
+
shared: SharedState,
|
|
59
|
+
): RunTimerHandle {
|
|
60
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
61
|
+
|
|
62
|
+
let currentRunStartMs: number | undefined;
|
|
63
|
+
let previousRunDurationMs: number | undefined;
|
|
64
|
+
let longestRunDurationMs: number | undefined;
|
|
65
|
+
|
|
66
|
+
let lastCtx: ExtensionContext | undefined;
|
|
67
|
+
|
|
68
|
+
function getState(): TimerState {
|
|
69
|
+
return {
|
|
70
|
+
currentRunStartMs,
|
|
71
|
+
previousRunDurationMs,
|
|
72
|
+
longestRunDurationMs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyState(state: TimerState): void {
|
|
77
|
+
currentRunStartMs = state.currentRunStartMs;
|
|
78
|
+
previousRunDurationMs = state.previousRunDurationMs;
|
|
79
|
+
longestRunDurationMs = state.longestRunDurationMs;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saveState(): void {
|
|
83
|
+
pi.appendEntry(STATE_TYPE, getState());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function restoreState(ctx: ExtensionContext): void {
|
|
87
|
+
for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
|
|
88
|
+
if (entry.type !== "custom") continue;
|
|
89
|
+
if (entry.customType !== STATE_TYPE && entry.customType !== LEGACY_STATE_TYPE) continue;
|
|
90
|
+
if (isTimerState(entry.data)) {
|
|
91
|
+
applyState(entry.data);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function stopTimer(): void {
|
|
98
|
+
if (!timer) return;
|
|
99
|
+
clearInterval(timer);
|
|
100
|
+
timer = undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function startTimer(): void {
|
|
104
|
+
stopTimer();
|
|
105
|
+
// 运行期间每秒刷一次 footer,让计时文本跳动
|
|
106
|
+
timer = setInterval(() => {
|
|
107
|
+
if (!lastCtx?.hasUI) return;
|
|
108
|
+
shared.requestRender?.();
|
|
109
|
+
}, 1000);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pi.on("session_start", (_event, ctx) => {
|
|
113
|
+
lastCtx = ctx;
|
|
114
|
+
stopTimer();
|
|
115
|
+
restoreState(ctx);
|
|
116
|
+
if (!ctx.hasUI) return;
|
|
117
|
+
if (currentRunStartMs !== undefined) startTimer();
|
|
118
|
+
shared.requestRender?.();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
122
|
+
lastCtx = ctx;
|
|
123
|
+
if (!ctx.hasUI) return;
|
|
124
|
+
|
|
125
|
+
if (currentRunStartMs === undefined) {
|
|
126
|
+
currentRunStartMs = Date.now();
|
|
127
|
+
startTimer();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
shared.requestRender?.();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
134
|
+
lastCtx = ctx;
|
|
135
|
+
if (!ctx.hasUI) {
|
|
136
|
+
stopTimer();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (currentRunStartMs === undefined) {
|
|
140
|
+
shared.requestRender?.();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const duration = Date.now() - currentRunStartMs;
|
|
145
|
+
previousRunDurationMs = duration;
|
|
146
|
+
|
|
147
|
+
if (longestRunDurationMs === undefined || duration > longestRunDurationMs) {
|
|
148
|
+
longestRunDurationMs = duration;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
currentRunStartMs = undefined;
|
|
152
|
+
stopTimer();
|
|
153
|
+
saveState();
|
|
154
|
+
shared.requestRender?.();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
158
|
+
// 保存计时状态(persist 到 session),停掉 tick 定时器
|
|
159
|
+
saveState();
|
|
160
|
+
stopTimer();
|
|
161
|
+
lastCtx = undefined;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
getStatusText(theme: Theme): string {
|
|
166
|
+
const startMs = currentRunStartMs;
|
|
167
|
+
const working = startMs !== undefined;
|
|
168
|
+
|
|
169
|
+
const elapsed = startMs !== undefined ? formatDuration(Date.now() - startMs) : "idle";
|
|
170
|
+
const prev = previousRunDurationMs !== undefined ? formatDuration(previousRunDurationMs) : "--:--";
|
|
171
|
+
const max = longestRunDurationMs !== undefined ? formatDuration(longestRunDurationMs) : "--:--";
|
|
172
|
+
|
|
173
|
+
const indicator = working ? theme.fg("accent", "●") : theme.fg("dim", "○");
|
|
174
|
+
const elapsedText = working ? theme.fg("text", elapsed) : theme.fg("dim", elapsed);
|
|
175
|
+
const label = theme.fg("dim", " run ");
|
|
176
|
+
const stats = theme.fg("dim", ` · prev ${prev} · max ${max}`);
|
|
177
|
+
|
|
178
|
+
return `${indicator}${label}${elapsedText}${stats}`;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|