token-stats-timer 1.0.8 → 1.0.10
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 +9 -8
- package/index.ts +1 -7
- package/notify.ts +4 -30
- package/package.json +1 -1
- package/step-timer.ts +4 -85
- package/token-stats.ts +2 -7
- package/run-timer.ts +0 -181
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# token-stats-timer
|
|
2
2
|
|
|
3
|
-
`@
|
|
3
|
+
`@liziy/token-stats` 的修改版本。
|
|
4
4
|
|
|
5
5
|
一个扩展、一个 footer,同时提供 run 计时与 token 用量/配额监控。
|
|
6
6
|
|
|
@@ -23,10 +23,9 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
23
23
|
|
|
24
24
|
## macOS 完成通知
|
|
25
25
|
|
|
26
|
-
每次 run(agent
|
|
26
|
+
每次 run(agent 任务)结束后弹系统通知,区分两种结果:
|
|
27
27
|
|
|
28
28
|
- ✅ pi 执行完成 —— 正常结束(含耗时)
|
|
29
|
-
- ❌ pi 执行失败 —— 模型调用出错 / 工具执行失败(含错误摘要)
|
|
30
29
|
- ⏹ pi 已中止 —— 用户 Esc 中止
|
|
31
30
|
|
|
32
31
|
会话关闭时另有 👋 提示(可关)。通知经 `osascript` 发送,需在系统设置 → 通知 中允许终端 App 通知。
|
|
@@ -57,16 +56,19 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
57
56
|
## 任务计时(step-timer)
|
|
58
57
|
|
|
59
58
|
- 任务执行中:工作指示器(spinner 文案)显示 `Working... 01:02`(整体已耗时,每秒刷新)
|
|
60
|
-
-
|
|
61
|
-
- 完成后:会话末尾插入一条汇总 `总耗时 01:23`(失败时 ❌,中止时 ⏹),经 `appendEntry` 持久化、不进入 LLM 上下文,`/resume` 后仍在
|
|
59
|
+
- 完成后:会话末尾插入一条汇总,仅显示总耗时:
|
|
62
60
|
|
|
63
|
-
|
|
61
|
+
`总耗时 01:23`
|
|
62
|
+
|
|
63
|
+
经 `appendEntry` 持久化、不进入 LLM 上下文,`/resume` 后仍在;无主题色样式。
|
|
64
|
+
|
|
65
|
+
无独立开关,随包启用;计时口径与 run-timer 一致(一次 run = 首个 agent_start → agent_settled,含重试/压缩/排队提示)。早期版本的每 turn 摘要(timing-turn)、“⏱ 思考”每条消息内联标注与 Thinking 实时标签均已移除,历史条目自动隐藏。
|
|
64
66
|
|
|
65
67
|
## 命令
|
|
66
68
|
|
|
67
69
|
- `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐)
|
|
68
70
|
- `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` —— 统计查询
|
|
69
|
-
- `/stats config` —— 显示样式 /
|
|
71
|
+
- `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间
|
|
70
72
|
- `/notify [on|off]` —— macOS 完成通知开关(无参查看状态)
|
|
71
73
|
|
|
72
74
|
## 与两个原包的兼容性
|
|
@@ -78,7 +80,6 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
78
80
|
## 安装(替换两个原包)
|
|
79
81
|
|
|
80
82
|
```bash
|
|
81
|
-
pi remove npm:@carlosgtrz/pi-run-timer
|
|
82
83
|
pi remove npm:@liziy/token-stats
|
|
83
84
|
pi install npm:token-stats-timer
|
|
84
85
|
```
|
package/index.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
|
|
19
19
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
21
|
-
import { createRunTimer } from "./run-timer.ts";
|
|
22
21
|
import { createTokenStats, formatUserPath, type SharedState } from "./token-stats.ts";
|
|
23
22
|
import { createNotifier } from "./notify.ts";
|
|
24
23
|
import { createStepTimer } from "./step-timer.ts";
|
|
@@ -30,7 +29,6 @@ const shared: SharedState = {
|
|
|
30
29
|
|
|
31
30
|
export default function runTokenStatsExtension(pi: ExtensionAPI) {
|
|
32
31
|
const stats = createTokenStats(pi, shared);
|
|
33
|
-
const timer = createRunTimer(pi, shared);
|
|
34
32
|
// macOS 完成通知(成功/失败/中止),配置见 notify-config.json
|
|
35
33
|
createNotifier(pi);
|
|
36
34
|
// 每步耗时:Thinking.../Working... 实时耗时 + 每 turn/总耗时会话摘要
|
|
@@ -54,13 +52,9 @@ export default function runTokenStatsExtension(pi: ExtensionAPI) {
|
|
|
54
52
|
// session 替换后旧 footer 可能仍被 TUI 渲染,此时 ctx 已失效,直接返回空
|
|
55
53
|
if (!shared.sessionActive) return [];
|
|
56
54
|
|
|
57
|
-
// ──
|
|
58
|
-
// 计时器放在指标行末尾(紧邻右侧模型名)
|
|
55
|
+
// ── 上行:指标左对齐,模型名右对齐 ──
|
|
59
56
|
const leftParts: string[] = [];
|
|
60
57
|
leftParts.push(...stats.getMetricParts(theme, ctx));
|
|
61
|
-
if (stats.isTimerEnabled()) {
|
|
62
|
-
leftParts.push(timer.getStatusText(theme));
|
|
63
|
-
}
|
|
64
58
|
const left = leftParts.join(" | ");
|
|
65
59
|
|
|
66
60
|
const modelName = ctx.model?.id || "";
|
package/notify.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// enabled 总开关(默认 true)
|
|
10
10
|
// minDurationSec 时长低于该秒数的 run 不通知(默认 0 = 每次都通知)
|
|
11
11
|
// sound 通知声音(默认 "Glass","" 表示静音)
|
|
12
|
-
// onSuccess /
|
|
12
|
+
// onSuccess / onAbort / onSessionEnd 各类通知开关
|
|
13
13
|
//
|
|
14
14
|
// 命令:/notify on | off | status
|
|
15
15
|
|
|
@@ -27,7 +27,6 @@ export interface NotifyConfig {
|
|
|
27
27
|
minDurationSec: number;
|
|
28
28
|
sound: string;
|
|
29
29
|
onSuccess: boolean;
|
|
30
|
-
onFailure: boolean;
|
|
31
30
|
onAbort: boolean;
|
|
32
31
|
onSessionEnd: boolean;
|
|
33
32
|
}
|
|
@@ -37,7 +36,6 @@ const DEFAULT_CONFIG: NotifyConfig = {
|
|
|
37
36
|
minDurationSec: 0,
|
|
38
37
|
sound: "Glass",
|
|
39
38
|
onSuccess: true,
|
|
40
|
-
onFailure: true,
|
|
41
39
|
onAbort: false,
|
|
42
40
|
onSessionEnd: false,
|
|
43
41
|
};
|
|
@@ -80,9 +78,7 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
80
78
|
|
|
81
79
|
// 当前 run 的状态(agent_start 重置,agent_end 收集,agent_settled 消费)
|
|
82
80
|
let runStartMs: number | undefined;
|
|
83
|
-
let runFailed = false;
|
|
84
81
|
let runAborted = false;
|
|
85
|
-
let runErrorMsg: string | undefined;
|
|
86
82
|
|
|
87
83
|
// 进程内去抖计时
|
|
88
84
|
let lastNotifyAt = 0;
|
|
@@ -102,9 +98,7 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
102
98
|
}
|
|
103
99
|
|
|
104
100
|
pi.on("agent_start", () => {
|
|
105
|
-
runFailed = false;
|
|
106
101
|
runAborted = false;
|
|
107
|
-
runErrorMsg = undefined;
|
|
108
102
|
if (runStartMs === undefined) runStartMs = Date.now();
|
|
109
103
|
});
|
|
110
104
|
|
|
@@ -115,17 +109,7 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
115
109
|
if (m.role === "assistant") {
|
|
116
110
|
if (m.stopReason === "aborted") {
|
|
117
111
|
runAborted = true;
|
|
118
|
-
runErrorMsg = runErrorMsg || "被用户中止";
|
|
119
|
-
} else if (m.stopReason === "error" || m.errorMessage) {
|
|
120
|
-
runFailed = true;
|
|
121
|
-
runErrorMsg = runErrorMsg || m.errorMessage || "模型调用出错";
|
|
122
112
|
}
|
|
123
|
-
} else if (m.role === "toolResult" && m.isError) {
|
|
124
|
-
runFailed = true;
|
|
125
|
-
const toolMsg = `工具 ${m.toolName} 执行失败`;
|
|
126
|
-
runErrorMsg = runErrorMsg && !runErrorMsg.startsWith("工具 ")
|
|
127
|
-
? runErrorMsg
|
|
128
|
-
: toolMsg;
|
|
129
113
|
}
|
|
130
114
|
}
|
|
131
115
|
});
|
|
@@ -142,19 +126,11 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
142
126
|
|
|
143
127
|
const dur = startMs !== undefined ? `${formatDuration(durationMs)}` : "-";
|
|
144
128
|
|
|
145
|
-
if (
|
|
146
|
-
if (config.onFailure) {
|
|
147
|
-
notify(
|
|
148
|
-
"❌ pi 执行失败",
|
|
149
|
-
`耗时:${dur}\n${truncate(runErrorMsg || "未知错误")}`,
|
|
150
|
-
config.sound,
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
} else if (runAborted) {
|
|
129
|
+
if (runAborted) {
|
|
154
130
|
if (config.onAbort) {
|
|
155
131
|
notify(
|
|
156
132
|
"⏹ pi 已中止",
|
|
157
|
-
`耗时:${dur}\n${truncate(
|
|
133
|
+
`耗时:${dur}\n${truncate("执行被中止")}`,
|
|
158
134
|
config.sound,
|
|
159
135
|
);
|
|
160
136
|
}
|
|
@@ -167,9 +143,7 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
167
143
|
}
|
|
168
144
|
|
|
169
145
|
// 消费本次结果,避免串扰下一次 run
|
|
170
|
-
runFailed = false;
|
|
171
146
|
runAborted = false;
|
|
172
|
-
runErrorMsg = undefined;
|
|
173
147
|
});
|
|
174
148
|
|
|
175
149
|
// 会话关闭时提示(可选)
|
|
@@ -196,7 +170,7 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
196
170
|
const sound = config.sound ? `, 声音=${config.sound}` : ", 静音";
|
|
197
171
|
ctx.ui.notify(
|
|
198
172
|
`完成通知: ${config.enabled ? "开" : "关"}, 最短时长=${config.minDurationSec}s, ` +
|
|
199
|
-
`成功=${config.onSuccess ? "开" : "关"},
|
|
173
|
+
`成功=${config.onSuccess ? "开" : "关"}, ` +
|
|
200
174
|
`中止=${config.onAbort ? "开" : "关"}, 会话结束=${config.onSessionEnd ? "开" : "关"}${sound}`,
|
|
201
175
|
"info",
|
|
202
176
|
);
|
package/package.json
CHANGED
package/step-timer.ts
CHANGED
|
@@ -2,13 +2,8 @@
|
|
|
2
2
|
// =============================================================================
|
|
3
3
|
// 实时显示(仅交互 TUI):
|
|
4
4
|
// - 任务执行中:工作指示器文案显示 "Working... 01:02"(整体已耗时,每秒刷新)
|
|
5
|
-
// 逐条内联标注(每条 assistant 消息末尾自动追加一行):
|
|
6
|
-
// - 例:`ai 回复内容...` 后另起一行 `> ⏱ 思考 00:05`
|
|
7
|
-
// - 当前正在生成的消息:该行随流式内容实时更新(思考计时);消息提交后永久定格
|
|
8
|
-
// - 历史消息各自保留提交时的耗时,互不影响、不再变化(标注已存在则跳过,resume 回放不显示)
|
|
9
|
-
// (pi 的 hiddenThinkingLabel 文案是全局的、无法逐条保留,故改用内联标注)
|
|
10
5
|
// 完成后汇总(appendEntry 持久化,不进入 LLM 上下文,resume 后可回看):
|
|
11
|
-
// - agent_settled appendEntry("timing-final", …)
|
|
6
|
+
// - agent_settled appendEntry("timing-final", …):仅本次任务总耗时
|
|
12
7
|
//
|
|
13
8
|
// 一次 run = 空闲后的首个 agent_start → agent_settled(与 run-timer 语义一致,
|
|
14
9
|
// 包含重试、压缩恢复和排队的 steering/follow-up 提示)。
|
|
@@ -19,8 +14,6 @@ import { Box, Text } from "@earendil-works/pi-tui";
|
|
|
19
14
|
/** 一次 run 的总耗时汇总(appendEntry "timing-final") */
|
|
20
15
|
export interface FinalTimingData {
|
|
21
16
|
totalMs: number;
|
|
22
|
-
failed: boolean;
|
|
23
|
-
aborted: boolean;
|
|
24
17
|
}
|
|
25
18
|
|
|
26
19
|
const FINAL_TYPE = "timing-final";
|
|
@@ -44,29 +37,6 @@ export function createStepTimer(pi: ExtensionAPI): void {
|
|
|
44
37
|
|
|
45
38
|
let runActive = false;
|
|
46
39
|
let runStartMs = 0;
|
|
47
|
-
let runFailed = false;
|
|
48
|
-
let runAborted = false;
|
|
49
|
-
|
|
50
|
-
// 逐条内联标注:当前消息的思考计时(每条 assistant 消息开始时重置)
|
|
51
|
-
// curThinkingMs:已结算的思考毫秒;thinkingStart:进行中思考块的起表时间
|
|
52
|
-
let curThinkingMs = 0;
|
|
53
|
-
let thinkingStart: number | undefined;
|
|
54
|
-
|
|
55
|
-
/** 结算当前进行中的思考块(若有) */
|
|
56
|
-
function settleThinking(now: number): void {
|
|
57
|
-
if (thinkingStart !== undefined) {
|
|
58
|
-
curThinkingMs += now - thinkingStart;
|
|
59
|
-
thinkingStart = undefined;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** 当前消息的思考总耗时(含进行中块):有思考数据才显示 */
|
|
64
|
-
function currentThinkingMs(now: number): number | undefined {
|
|
65
|
-
if (curThinkingMs === 0 && thinkingStart === undefined) return undefined;
|
|
66
|
-
return curThinkingMs + (thinkingStart !== undefined ? now - thinkingStart : 0);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const MARK_TEXT = "⏱ 思考";
|
|
70
40
|
|
|
71
41
|
function startTicker(): void {
|
|
72
42
|
stopTicker();
|
|
@@ -85,8 +55,6 @@ export function createStepTimer(pi: ExtensionAPI): void {
|
|
|
85
55
|
function appendFinal(): void {
|
|
86
56
|
pi.appendEntry<FinalTimingData>(FINAL_TYPE, {
|
|
87
57
|
totalMs: Date.now() - runStartMs,
|
|
88
|
-
failed: runFailed,
|
|
89
|
-
aborted: runAborted,
|
|
90
58
|
});
|
|
91
59
|
}
|
|
92
60
|
|
|
@@ -101,47 +69,10 @@ export function createStepTimer(pi: ExtensionAPI): void {
|
|
|
101
69
|
if (runActive) return;
|
|
102
70
|
runActive = true;
|
|
103
71
|
runStartMs = Date.now();
|
|
104
|
-
runFailed = false;
|
|
105
|
-
runAborted = false;
|
|
106
|
-
curThinkingMs = 0;
|
|
107
|
-
thinkingStart = undefined;
|
|
108
72
|
startTicker();
|
|
109
73
|
if (lastCtx?.hasUI) lastCtx.ui.setWorkingMessage("Working... 00:00");
|
|
110
74
|
});
|
|
111
75
|
|
|
112
|
-
// 每条 assistant 消息开始时重置思考计时
|
|
113
|
-
pi.on("message_start", (event, ctx) => {
|
|
114
|
-
if (!runActive || event.message.role !== "assistant") return;
|
|
115
|
-
lastCtx = ctx;
|
|
116
|
-
curThinkingMs = 0;
|
|
117
|
-
thinkingStart = undefined;
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
pi.on("message_update", (event, ctx) => {
|
|
121
|
-
if (!runActive) return;
|
|
122
|
-
lastCtx = ctx;
|
|
123
|
-
const ev = event.assistantMessageEvent;
|
|
124
|
-
if (!ev) return;
|
|
125
|
-
const now = Date.now();
|
|
126
|
-
if (ev.type === "thinking_start") {
|
|
127
|
-
if (thinkingStart === undefined) thinkingStart = now;
|
|
128
|
-
} else if (ev.type === "thinking_end" || ev.type === "text_start") {
|
|
129
|
-
settleThinking(now);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
pi.on("tool_execution_start", (_event, ctx) => {
|
|
134
|
-
if (!runActive) return;
|
|
135
|
-
lastCtx = ctx;
|
|
136
|
-
// 工具执行通常在同一消息的 toolCall 内容之后:结算思考计时
|
|
137
|
-
settleThinking(Date.now());
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
pi.on("agent_end", (event, ctx) => {
|
|
141
|
-
if (!runActive) return;
|
|
142
|
-
lastCtx = ctx;
|
|
143
|
-
});
|
|
144
|
-
|
|
145
76
|
pi.on("agent_settled", (_event, ctx) => {
|
|
146
77
|
lastCtx = ctx;
|
|
147
78
|
if (!runActive) return;
|
|
@@ -161,23 +92,11 @@ export function createStepTimer(pi: ExtensionAPI): void {
|
|
|
161
92
|
lastCtx = undefined;
|
|
162
93
|
});
|
|
163
94
|
|
|
164
|
-
|
|
165
|
-
// 只在 assistant 文本上追加一行耗时;已带标注(历史消息重渲染)则跳过保持原值
|
|
166
|
-
pi.registerMarkdownTransformer((markdown, context) => {
|
|
167
|
-
if (context.messageType !== "assistant") return markdown;
|
|
168
|
-
if (markdown.includes(MARK_TEXT)) return markdown;
|
|
169
|
-
const now = Date.now();
|
|
170
|
-
const ms = currentThinkingMs(now);
|
|
171
|
-
if (ms === undefined) return markdown;
|
|
172
|
-
return `${markdown}\n\n> ${MARK_TEXT} ${formatDuration(ms)}`;
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
pi.registerEntryRenderer<FinalTimingData>(FINAL_TYPE, (entry, _opts, theme) => {
|
|
95
|
+
pi.registerEntryRenderer<FinalTimingData>(FINAL_TYPE, (entry, _opts, _theme) => {
|
|
176
96
|
const d = entry.data;
|
|
177
97
|
if (!d) return undefined;
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
box.addChild(new Text(`${title} ${formatDuration(d.totalMs)}`, 0, 0));
|
|
98
|
+
const box = new Box(1, 1);
|
|
99
|
+
box.addChild(new Text(`总耗时 ${formatDuration(d.totalMs)}`, 0, 0));
|
|
181
100
|
return box;
|
|
182
101
|
});
|
|
183
102
|
}
|
package/token-stats.ts
CHANGED
|
@@ -138,7 +138,6 @@ export type DisplayKey =
|
|
|
138
138
|
| "quota5h" // 5h 额度
|
|
139
139
|
| "quotaWeek" // 周额度
|
|
140
140
|
| "quotaClock" // 刷新时间(⏱)
|
|
141
|
-
| "timer"; // run 计时(⏱ run/prev/max)
|
|
142
141
|
|
|
143
142
|
export interface DisplayConfig {
|
|
144
143
|
items: Record<DisplayKey, boolean>;
|
|
@@ -465,7 +464,6 @@ const DEFAULT_DISPLAY_CONFIG: DisplayConfig = {
|
|
|
465
464
|
quota5h: true,
|
|
466
465
|
quotaWeek: true,
|
|
467
466
|
quotaClock: true,
|
|
468
|
-
timer: true,
|
|
469
467
|
},
|
|
470
468
|
contextStyle: "pct-window",
|
|
471
469
|
speedStyle: "t/s",
|
|
@@ -476,8 +474,6 @@ const DEFAULT_DISPLAY_CONFIG: DisplayConfig = {
|
|
|
476
474
|
export interface TokenStatsHandle {
|
|
477
475
|
/** footer 上行指标段(不含 run 计时;计时由 index.ts 拼接) */
|
|
478
476
|
getMetricParts(theme: Theme, ctx: ExtensionContext): string[];
|
|
479
|
-
/** 是否显示 run 计时段 */
|
|
480
|
-
isTimerEnabled(): boolean;
|
|
481
477
|
}
|
|
482
478
|
|
|
483
479
|
export function createTokenStats(
|
|
@@ -1750,10 +1746,10 @@ export function createTokenStats(
|
|
|
1750
1746
|
} else if (subChoice === "显示内容") {
|
|
1751
1747
|
const itemLabels: DisplayKey[] = [
|
|
1752
1748
|
"input", "output", "totalTokens", "cacheHit", "speed", "context",
|
|
1753
|
-
"quota5h", "quotaWeek", "quotaClock",
|
|
1749
|
+
"quota5h", "quotaWeek", "quotaClock",
|
|
1754
1750
|
];
|
|
1755
1751
|
const itemNames: Record<DisplayKey, string> = {
|
|
1756
|
-
|
|
1752
|
+
input: "输入", output: "输出", totalTokens: "总token",
|
|
1757
1753
|
cacheHit: "缓存命中", speed: "速度", context: "容量",
|
|
1758
1754
|
quota5h: "5h额度", quotaWeek: "周额度", quotaClock: "刷新时间",
|
|
1759
1755
|
};
|
|
@@ -1843,6 +1839,5 @@ export function createTokenStats(
|
|
|
1843
1839
|
|
|
1844
1840
|
return {
|
|
1845
1841
|
getMetricParts,
|
|
1846
|
-
isTimerEnabled: () => displayConfig.items.timer === true,
|
|
1847
1842
|
};
|
|
1848
1843
|
}
|
package/run-timer.ts
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
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
|
-
}
|