pi-ark-usage 0.1.3 → 0.1.5
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 +4 -5
- package/extensions/index.ts +23 -107
- package/extensions/lib/types.ts +0 -7
- package/extensions/lib/widget.ts +32 -251
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,17 +3,16 @@
|
|
|
3
3
|
在 pi TUI 中显示**火山引擎方舟 Coding Plan / Agent Plan** 的套餐用量,数据直接来自火山方舟 OpenAPI `GetCodingPlanUsage`(AK/SK 签名,官方 [`@volcengine/openapi`](https://github.com/volcengine/volc-sdk-nodejs) SDK 的 Signer)。
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
|
|
6
|
+
1% 4h38m | 15% 5d4h | 54% 23d4h | 3m ago
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
## 特性
|
|
10
10
|
|
|
11
11
|
- **AK/SK 直连 OpenAPI**:不走子进程、不依赖 SSO 登录态;凭据只从环境变量或配置文件读取,本扩展不保存、不上传任何 Key
|
|
12
12
|
- **三个周期同屏**:session(会话)/ weekly(周)/ monthly(月),并显示各周期重置倒计时
|
|
13
|
-
- **Powerline
|
|
13
|
+
- **Powerline 状态段(唯一展示位)**:通过 `ctx.ui.setStatus("ark-usage", …)` 发布单行摘要(`1% 4h38m | 15% 5d4h | 54% 23d4h | 3m ago`,固定按 session/weekly/monthly 顺序),显示在 [pi-powerline-footer](https://www.npmjs.com/package/pi-powerline-footer) 上;百分比按阈值绿/黄/红着色,时间部分紫色,分隔符跟随主题 `thinkingHigh`(编辑器边框色),末尾的「上次刷新」用 `dim` 渲染以示区隔。不安装 powerline 时也可通过内置 footer 的 extension statuses 区域或 `/arkusage` 查看
|
|
14
|
+
- **上次刷新时间**:状态段末尾显示距今多久前**成功**刷新过(分钟精度:`just now` / `3m ago` / `4h38m ago` / `5d4h ago`)。抓取失败时沿用上一次成功的时间,因此该值持续变大即表示刷新已经失败或长时间未刷新
|
|
14
15
|
- **阈值着色**:低用量绿色(<50%)、接近上限黄色(50–79%)、超过红线红色(≥80%,默认值,可用 `/arkset` 调整)
|
|
15
|
-
- **单轮消耗**:以每轮对话开始前的快照为基线,结算时标注各周期增量(如 `(+2)`)
|
|
16
|
-
- **产品切换感知**:在个人版/团队版、Coding Plan/Agent Plan 之间切换时标注 `(变更)`;套餐周期重置时标注 `(已重置)`
|
|
17
16
|
- **持久化缓存**:重启 pi 后立即显示上次快照(`~/.pi/agent/pi-ark-usage/cache.json`,0600 权限,临时文件原子写入)
|
|
18
17
|
- 可选空闲自动刷新(默认关闭)
|
|
19
18
|
|
|
@@ -99,7 +98,7 @@ pi install npm:pi-ark-usage
|
|
|
99
98
|
1. 用官方 `@volcengine/openapi` 的 `Signer` 对 `POST open.volcengineapi.com?Action=GetCodingPlanUsage&Version=2024-01-01`(service `ark`,region `cn-beijing`)做 V4 签名后 fetch。
|
|
100
99
|
2. 解析 `Result.QuotaUsage[]`(`Level/Percent/ResetTimestamp`,秒级时间戳转 `+08:00` ISO 字符串),做结构与取值校验。
|
|
101
100
|
3. 快照存入内存 Map,并以原子写(tmp 文件 + rename)持久化到 `~/.pi/agent/pi-ark-usage/`。
|
|
102
|
-
4.
|
|
101
|
+
4. 摘要通过 `ctx.ui.setStatus("ark-usage", …)` 发布到 extension status,由 pi-powerline-footer 渲染到 powerline 行;每分钟 ticker 在空闲时重发,保持重置倒计时与「上次刷新」时长显示为最新。
|
|
103
102
|
|
|
104
103
|
## 开发
|
|
105
104
|
|
package/extensions/index.ts
CHANGED
|
@@ -6,14 +6,13 @@
|
|
|
6
6
|
* the official `@volcengine/openapi` Signer. This extension never stores keys:
|
|
7
7
|
* credentials come from VOLC_ACCESSKEY/VOLC_SECRETKEY or ~/.volc/config.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Powerline status (via `ctx.ui.setStatus`):
|
|
10
|
+
* 1% 4h38m | 15% 5d4h | 54% 23d4h | 3m ago
|
|
11
|
+
* The trailing segment is how long ago the data was last refreshed.
|
|
10
12
|
* Commands:
|
|
11
13
|
* /arkcheck force refresh
|
|
12
14
|
* /arkusage show snapshot in a notification
|
|
13
15
|
* /arkset ... view/change settings
|
|
14
|
-
*
|
|
15
|
-
* Design modeled after pi-check-agent-quota: baseline snapshot taken at
|
|
16
|
-
* agent_start, compared at agent_settled to annotate per-round consumption.
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
18
|
import type {
|
|
@@ -23,11 +22,9 @@ import type {
|
|
|
23
22
|
} from "@earendil-works/pi-coding-agent";
|
|
24
23
|
import { fetchPlan } from "./lib/arkcli.js";
|
|
25
24
|
import {
|
|
26
|
-
ArkUsageComponent,
|
|
27
25
|
AUTO_REFRESH_MAX_MINUTES,
|
|
28
26
|
LOCALES,
|
|
29
27
|
buildCompactStatus,
|
|
30
|
-
buildItems,
|
|
31
28
|
formatRemaining,
|
|
32
29
|
getSettings,
|
|
33
30
|
patchSettings,
|
|
@@ -45,10 +42,8 @@ import type {
|
|
|
45
42
|
ArkSettings,
|
|
46
43
|
Language,
|
|
47
44
|
PlanSnapshot,
|
|
48
|
-
RenderItem,
|
|
49
45
|
} from "./lib/types.js";
|
|
50
46
|
|
|
51
|
-
const WIDGET_KEY = "pi-ark-usage";
|
|
52
47
|
/** Extension-status key consumed by pi-powerline-footer (powerline slot). */
|
|
53
48
|
const STATUS_KEY = "ark-usage";
|
|
54
49
|
const TICK_MS = 60_000;
|
|
@@ -87,10 +82,6 @@ let language: Language = "en";
|
|
|
87
82
|
const snapshots = new Map<string, PlanSnapshot>();
|
|
88
83
|
let activeProduct: ProductId | null = null;
|
|
89
84
|
let status: Status = "ok";
|
|
90
|
-
let deltas: Record<string, number> | null = null;
|
|
91
|
-
let note: "changed" | "reset" | null = null;
|
|
92
|
-
let baseline: PlanSnapshot | null = null;
|
|
93
|
-
let activeWidget: ArkUsageComponent | null = null;
|
|
94
85
|
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
95
86
|
let lastCtx: ExtensionContext | null = null;
|
|
96
87
|
let lastAutoAt = 0;
|
|
@@ -128,20 +119,12 @@ function activeSnapshot(): PlanSnapshot | null {
|
|
|
128
119
|
|
|
129
120
|
/* -------------------------------- rendering -------------------------------- */
|
|
130
121
|
|
|
131
|
-
function
|
|
122
|
+
function renderStatus(ctx: ExtensionContext): void {
|
|
132
123
|
lastCtx = ctx;
|
|
133
124
|
const snap = activeSnapshot();
|
|
134
125
|
const widgetStatus = status === "ok" && !snap ? "none" : status;
|
|
135
|
-
const items: RenderItem[] = buildItems(
|
|
136
|
-
snap,
|
|
137
|
-
language,
|
|
138
|
-
widgetStatus,
|
|
139
|
-
ctx.isIdle(),
|
|
140
|
-
deltas,
|
|
141
|
-
note,
|
|
142
|
-
);
|
|
143
126
|
// Publish the one-line summary to the extension-status slot (powerline).
|
|
144
|
-
// The ticker calls
|
|
127
|
+
// The ticker calls renderStatus every minute while idle, keeping the
|
|
145
128
|
// reset countdown fresh without refetching.
|
|
146
129
|
const compact = buildCompactStatus(snap, widgetStatus, ctx.ui.theme);
|
|
147
130
|
if (compact) {
|
|
@@ -149,23 +132,6 @@ function renderWidget(ctx: ExtensionContext): void {
|
|
|
149
132
|
} else {
|
|
150
133
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
151
134
|
}
|
|
152
|
-
if (activeWidget) {
|
|
153
|
-
activeWidget.update(items);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
ctx.ui.setWidget(
|
|
157
|
-
WIDGET_KEY,
|
|
158
|
-
(tui, theme) => {
|
|
159
|
-
const component = new ArkUsageComponent(
|
|
160
|
-
items,
|
|
161
|
-
() => theme,
|
|
162
|
-
() => tui.requestRender?.(),
|
|
163
|
-
);
|
|
164
|
-
activeWidget = component;
|
|
165
|
-
return component;
|
|
166
|
-
},
|
|
167
|
-
{ placement: "belowEditor" },
|
|
168
|
-
);
|
|
169
135
|
}
|
|
170
136
|
|
|
171
137
|
/* --------------------------------- fetching -------------------------------- */
|
|
@@ -193,7 +159,7 @@ async function refresh(
|
|
|
193
159
|
activeProduct = product;
|
|
194
160
|
if (!product) {
|
|
195
161
|
status = "none";
|
|
196
|
-
|
|
162
|
+
renderStatus(ctx);
|
|
197
163
|
return false;
|
|
198
164
|
}
|
|
199
165
|
|
|
@@ -203,8 +169,7 @@ async function refresh(
|
|
|
203
169
|
}
|
|
204
170
|
|
|
205
171
|
status = "fetching";
|
|
206
|
-
|
|
207
|
-
renderWidget(ctx);
|
|
172
|
+
renderStatus(ctx);
|
|
208
173
|
|
|
209
174
|
const controller = new AbortController();
|
|
210
175
|
inFlight = controller;
|
|
@@ -242,7 +207,7 @@ async function refresh(
|
|
|
242
207
|
return false;
|
|
243
208
|
} finally {
|
|
244
209
|
if (inFlight === controller) inFlight = null;
|
|
245
|
-
|
|
210
|
+
renderStatus(ctx);
|
|
246
211
|
}
|
|
247
212
|
}
|
|
248
213
|
|
|
@@ -252,66 +217,21 @@ function persist(): void {
|
|
|
252
217
|
saveCache(language, out);
|
|
253
218
|
}
|
|
254
219
|
|
|
255
|
-
/*
|
|
256
|
-
|
|
257
|
-
function computeDeltas(
|
|
258
|
-
before: PlanSnapshot,
|
|
259
|
-
after: PlanSnapshot,
|
|
260
|
-
): Record<string, number> | null {
|
|
261
|
-
const out: Record<string, number> = {};
|
|
262
|
-
for (const p of after.periods) {
|
|
263
|
-
const old = before.periods.find((q) => q.label === p.label);
|
|
264
|
-
if (old) out[p.label] = Math.round(old.percent - p.percent);
|
|
265
|
-
// note: stored as consumed-positive; renderer negates for display
|
|
266
|
-
}
|
|
267
|
-
// flip sign convention for renderer: it wants negative=consumed
|
|
268
|
-
const render: Record<string, number> = {};
|
|
269
|
-
for (const [k, v] of Object.entries(out)) render[k] = -v;
|
|
270
|
-
return render;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function looksReset(a: PlanSnapshot, b: PlanSnapshot): boolean {
|
|
274
|
-
// all periods dropped sharply at once => plan period reset
|
|
275
|
-
return b.periods.every((p) => {
|
|
276
|
-
const old = a.periods.find((q) => q.label === p.label);
|
|
277
|
-
return old !== undefined && p.percent - old.percent < -30;
|
|
278
|
-
});
|
|
279
|
-
}
|
|
220
|
+
/* ----------------------- session-start / settled --------------------------- */
|
|
280
221
|
|
|
281
222
|
async function handleStart(ctx: ExtensionContext): Promise<void> {
|
|
282
223
|
const product = resolveProduct(ctx);
|
|
283
224
|
if (!product) return;
|
|
284
225
|
const cached = snapshots.get(product) ?? null;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const win = await timeoutResult(refresh(ctx, "stale"), AGENT_START_TIMEOUT_MS);
|
|
289
|
-
baseline = win ? snapshots.get(product) ?? null : cached;
|
|
226
|
+
// Only refetch at agent_start when the cached snapshot went stale.
|
|
227
|
+
if (!cached || Date.now() - cached.fetchedAt > BASELINE_FRESH_MS) {
|
|
228
|
+
await timeoutResult(refresh(ctx, "stale"), AGENT_START_TIMEOUT_MS);
|
|
290
229
|
}
|
|
291
|
-
|
|
292
|
-
renderWidget(ctx);
|
|
230
|
+
renderStatus(ctx);
|
|
293
231
|
}
|
|
294
232
|
|
|
295
233
|
async function handleSettled(ctx: ExtensionContext): Promise<void> {
|
|
296
|
-
|
|
297
|
-
if (!product) return;
|
|
298
|
-
const ok = await refresh(ctx, "settled");
|
|
299
|
-
if (!ok) return;
|
|
300
|
-
const snap = snapshots.get(product);
|
|
301
|
-
if (!snap) return;
|
|
302
|
-
|
|
303
|
-
if (baseline && baseline.product !== product) {
|
|
304
|
-
note = "changed";
|
|
305
|
-
deltas = null;
|
|
306
|
-
} else if (baseline && looksReset(baseline, snap)) {
|
|
307
|
-
note = "reset";
|
|
308
|
-
deltas = null;
|
|
309
|
-
} else if (baseline) {
|
|
310
|
-
deltas = computeDeltas(baseline, snap);
|
|
311
|
-
note = null;
|
|
312
|
-
}
|
|
313
|
-
baseline = null;
|
|
314
|
-
renderWidget(ctx);
|
|
234
|
+
await refresh(ctx, "settled");
|
|
315
235
|
}
|
|
316
236
|
|
|
317
237
|
async function timeoutResult<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
|
@@ -342,7 +262,7 @@ function ensureTicker(): void {
|
|
|
342
262
|
return;
|
|
343
263
|
}
|
|
344
264
|
// age text ticks every minute even without refetch
|
|
345
|
-
if (
|
|
265
|
+
if (lastCtx && lastCtx.isIdle()) renderStatus(lastCtx);
|
|
346
266
|
}, TICK_MS);
|
|
347
267
|
tickTimer.unref?.();
|
|
348
268
|
}
|
|
@@ -408,7 +328,7 @@ async function cmdSet(
|
|
|
408
328
|
const notifySaved = (): void => {
|
|
409
329
|
persist();
|
|
410
330
|
ctx.ui.notify(locale.settingsApplied, "info");
|
|
411
|
-
if (lastCtx)
|
|
331
|
+
if (lastCtx) renderStatus(lastCtx);
|
|
412
332
|
};
|
|
413
333
|
|
|
414
334
|
if (parts.length === 0) {
|
|
@@ -422,7 +342,7 @@ async function cmdSet(
|
|
|
422
342
|
resetSettings();
|
|
423
343
|
persist();
|
|
424
344
|
ctx.ui.notify(locale.settingsReset, "info");
|
|
425
|
-
if (lastCtx)
|
|
345
|
+
if (lastCtx) renderStatus(lastCtx);
|
|
426
346
|
return;
|
|
427
347
|
}
|
|
428
348
|
case "yellow":
|
|
@@ -499,7 +419,7 @@ async function cmdSet(
|
|
|
499
419
|
language = raw;
|
|
500
420
|
persist();
|
|
501
421
|
ctx.ui.notify(locale.languageChanged(language), "info");
|
|
502
|
-
if (lastCtx)
|
|
422
|
+
if (lastCtx) renderStatus(lastCtx);
|
|
503
423
|
return;
|
|
504
424
|
}
|
|
505
425
|
default:
|
|
@@ -532,25 +452,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
532
452
|
});
|
|
533
453
|
|
|
534
454
|
pi.on("session_start", (_event, ctx) => {
|
|
535
|
-
// Session switches reuse this module instance in-process: pi has
|
|
536
|
-
//
|
|
537
|
-
//
|
|
455
|
+
// Session switches reuse this module instance in-process: pi has torn
|
|
456
|
+
// down the previous runtime, so revive the shutdown flags before doing
|
|
457
|
+
// anything else.
|
|
538
458
|
shuttingDown = false;
|
|
539
459
|
resetShutdownState();
|
|
540
|
-
activeWidget = null;
|
|
541
460
|
activeProduct = resolveProduct(ctx);
|
|
542
|
-
|
|
543
|
-
note = null;
|
|
544
|
-
renderWidget(ctx);
|
|
461
|
+
renderStatus(ctx);
|
|
545
462
|
ensureTicker();
|
|
546
463
|
void refresh(ctx, "session_start");
|
|
547
464
|
});
|
|
548
465
|
|
|
549
466
|
pi.on("model_select", (_event, ctx) => {
|
|
550
467
|
const next = resolveProduct(ctx);
|
|
551
|
-
if (baseline && next && baseline.product !== next) note = "changed";
|
|
552
468
|
activeProduct = next;
|
|
553
|
-
|
|
469
|
+
renderStatus(ctx);
|
|
554
470
|
void refresh(ctx, "model_select");
|
|
555
471
|
});
|
|
556
472
|
|
package/extensions/lib/types.ts
CHANGED
|
@@ -4,13 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
export type Language = "zh" | "en";
|
|
6
6
|
|
|
7
|
-
/** Render model, modeled after pi-check-agent-quota. */
|
|
8
|
-
export type RenderItem =
|
|
9
|
-
| { kind: "text"; text: string }
|
|
10
|
-
| { kind: "pct"; pct: number; metric: string }
|
|
11
|
-
| { kind: "annotation"; text: string }
|
|
12
|
-
| { kind: "age"; text: string };
|
|
13
|
-
|
|
14
7
|
/** User-tunable settings (persisted in the disk cache). */
|
|
15
8
|
export type ArkSettings = {
|
|
16
9
|
/** yellow threshold, percent */
|
package/extensions/lib/widget.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rendering
|
|
3
|
-
*
|
|
2
|
+
* Rendering for the powerline extension-status slot: locales, threshold
|
|
3
|
+
* settings, percent coloring, remaining-time formatting, and the compact
|
|
4
|
+
* colored one-line summary published via `ctx.ui.setStatus`.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type { ArkSettings, Language, PlanSnapshot
|
|
8
|
+
import type { ArkSettings, Language, PlanSnapshot } from "./types.js";
|
|
8
9
|
|
|
9
10
|
/* ---------------------------------- i18n ---------------------------------- */
|
|
10
11
|
|
|
@@ -20,10 +21,6 @@ type Locale = {
|
|
|
20
21
|
changed: string;
|
|
21
22
|
reset: string;
|
|
22
23
|
resetsIn: (t: string) => string;
|
|
23
|
-
ageNow: string;
|
|
24
|
-
ageMinutesAgo: (m: number) => string;
|
|
25
|
-
ageHoursAgo: (h: number, m: number) => string;
|
|
26
|
-
ageDaysAgo: (d: number, h: number) => string;
|
|
27
24
|
noActiveProduct: string;
|
|
28
25
|
settingsShow: (s: ArkSettings) => string;
|
|
29
26
|
settingsApplied: string;
|
|
@@ -56,10 +53,6 @@ const ZH: Locale = {
|
|
|
56
53
|
changed: "变更",
|
|
57
54
|
reset: "已重置",
|
|
58
55
|
resetsIn: (t) => `${t}后重置`,
|
|
59
|
-
ageNow: "刚刚",
|
|
60
|
-
ageMinutesAgo: (m) => `${m}分钟前`,
|
|
61
|
-
ageHoursAgo: (h, m) => (m > 0 ? `${h}小时${m}分前` : `${h}小时前`),
|
|
62
|
-
ageDaysAgo: (d, h) => (h > 0 ? `${d}天${h}小时前` : `${d}天前`),
|
|
63
56
|
noActiveProduct: "当前没有可查询的火山套餐",
|
|
64
57
|
settingsShow: (s) =>
|
|
65
58
|
`当前设置:产品=${s.product} 黄线=${s.pctYellow} 红线=${s.pctRed} 自动刷新=${s.autoRefreshMinutes}分钟 seat=${s.seat || "无"}`,
|
|
@@ -94,10 +87,6 @@ const EN: Locale = {
|
|
|
94
87
|
changed: "changed",
|
|
95
88
|
reset: "reset",
|
|
96
89
|
resetsIn: (t) => `reset after ${t}`,
|
|
97
|
-
ageNow: "now",
|
|
98
|
-
ageMinutesAgo: (m) => `${m}m ago`,
|
|
99
|
-
ageHoursAgo: (h, m) => (m > 0 ? `${h}h${m}m ago` : `${h}h ago`),
|
|
100
|
-
ageDaysAgo: (d, h) => (h > 0 ? `${d}d${h}h ago` : `${d}d ago`),
|
|
101
90
|
noActiveProduct: "No active Volcengine plan",
|
|
102
91
|
settingsShow: (s) =>
|
|
103
92
|
`Settings: product=${s.product} yellow=${s.pctYellow} red=${s.pctRed} autoRefresh=${s.autoRefreshMinutes}m seat=${s.seat || "none"}`,
|
|
@@ -196,19 +185,6 @@ export function formatRemaining(ms: number, lang: Language): string {
|
|
|
196
185
|
return `${m}m`;
|
|
197
186
|
}
|
|
198
187
|
|
|
199
|
-
export function formatAge(fetchedAt: number, lang: Language): string {
|
|
200
|
-
const age = Date.now() - fetchedAt;
|
|
201
|
-
if (!Number.isFinite(age) || age < 0) return "";
|
|
202
|
-
if (age < 60_000) return LOCALES[lang].ageNow;
|
|
203
|
-
const totalMin = Math.floor(age / 60_000);
|
|
204
|
-
const h = Math.floor(totalMin / 60);
|
|
205
|
-
const m = totalMin % 60;
|
|
206
|
-
const d = Math.floor(h / 24);
|
|
207
|
-
if (d > 0) return LOCALES[lang].ageDaysAgo(d, h % 24);
|
|
208
|
-
if (h > 0) return LOCALES[lang].ageHoursAgo(h, m);
|
|
209
|
-
return LOCALES[lang].ageMinutesAgo(m);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
188
|
/* ---------------------- compact status (powerline slot) -------------------- */
|
|
213
189
|
|
|
214
190
|
/**
|
|
@@ -226,9 +202,25 @@ export function formatCompactRemaining(ms: number): string {
|
|
|
226
202
|
return `${m}m`;
|
|
227
203
|
}
|
|
228
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Age of the last successful refresh, for the trailing status segment:
|
|
207
|
+
* `just now`, `3m ago`, `4h38m ago`, `5d4h ago`. Minute is the finest unit —
|
|
208
|
+
* seconds would only make the line jitter — and a stale cached snapshot thus
|
|
209
|
+
* reads as old at a glance.
|
|
210
|
+
*/
|
|
211
|
+
export function formatRefreshAgo(ts: number, now: number = Date.now()): string {
|
|
212
|
+
if (!Number.isFinite(ts) || ts <= 0) return "";
|
|
213
|
+
const ms = now - ts;
|
|
214
|
+
// Clock skew can make a fresh fetch look negative; treat it as "now".
|
|
215
|
+
if (ms < 60_000) return "just now";
|
|
216
|
+
// Drop a zero lower unit (`1h0m` -> `1h`, `5d0h` -> `5d`).
|
|
217
|
+
const age = formatCompactRemaining(ms).replace(/([dh])0[mh]$/, "$1");
|
|
218
|
+
return `${age} ago`;
|
|
219
|
+
}
|
|
220
|
+
|
|
229
221
|
/**
|
|
230
222
|
* One-line colored summary for the powerline extension-status slot:
|
|
231
|
-
* `1% 4h38m | 15% 5d4h | 54% 23d4h`
|
|
223
|
+
* `1% 4h38m | 15% 5d4h | 54% 23d4h | 3m ago`
|
|
232
224
|
*
|
|
233
225
|
* Periods are shown in API order (session / weekly / monthly) without
|
|
234
226
|
* labels — the order itself is the convention. Percent follows the
|
|
@@ -236,248 +228,37 @@ export function formatCompactRemaining(ms: number): string {
|
|
|
236
228
|
* red); the time part is always purple; the ` | ` separator uses the
|
|
237
229
|
* editor border color (`thinkingHigh`, pink in the dark theme).
|
|
238
230
|
*
|
|
231
|
+
* The last segment is how long ago the data was fetched, in `dim` — it is
|
|
232
|
+
* meta information, not usage, so it stays visually secondary.
|
|
233
|
+
*
|
|
239
234
|
* Returns null when nothing should be shown (the powerline item hides).
|
|
240
235
|
*/
|
|
241
236
|
export function buildCompactStatus(
|
|
242
237
|
snapshot: PlanSnapshot | null,
|
|
243
238
|
status: WidgetStatus,
|
|
244
239
|
theme: Theme,
|
|
240
|
+
now: number = Date.now(),
|
|
245
241
|
): string | null {
|
|
246
242
|
if (snapshot && snapshot.periods.length > 0) {
|
|
247
243
|
const sep = theme.fg("thinkingHigh", " | ");
|
|
248
|
-
|
|
244
|
+
const periods = snapshot.periods
|
|
249
245
|
.map((p) => {
|
|
250
246
|
const pct = coloredPct(clampPct(p.percent));
|
|
251
247
|
const reset = p.reset_at
|
|
252
|
-
? formatCompactRemaining(new Date(p.reset_at).getTime() -
|
|
248
|
+
? formatCompactRemaining(new Date(p.reset_at).getTime() - now)
|
|
253
249
|
: "";
|
|
254
250
|
return reset ? `${pct} ${hexFg(HEX_COLORS.purple, reset)}` : pct;
|
|
255
251
|
})
|
|
256
252
|
.join(sep);
|
|
253
|
+
const refreshed = formatRefreshAgo(snapshot.fetchedAt, now);
|
|
254
|
+
return refreshed
|
|
255
|
+
? `${periods}${sep}${theme.fg("dim", refreshed)}`
|
|
256
|
+
: periods;
|
|
257
257
|
}
|
|
258
258
|
if (status === "fetching") return hexFg(HEX_COLORS.purple, "…");
|
|
259
259
|
if (status === "failed") return hexFg(HEX_COLORS.red, "failed");
|
|
260
260
|
return null;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
|
|
264
|
-
|
|
263
|
+
/** Lifecycle states mirrored in the compact status. */
|
|
265
264
|
export type WidgetStatus = "ok" | "fetching" | "failed" | "no-binary" | "none";
|
|
266
|
-
|
|
267
|
-
export function buildItems(
|
|
268
|
-
snapshot: PlanSnapshot | null,
|
|
269
|
-
lang: Language,
|
|
270
|
-
status: WidgetStatus,
|
|
271
|
-
isIdle: boolean,
|
|
272
|
-
/** delta annotations keyed by period label; negative = consumed */
|
|
273
|
-
deltas: Record<string, number> | null,
|
|
274
|
-
note?: "changed" | "reset" | null,
|
|
275
|
-
): RenderItem[] {
|
|
276
|
-
const locale = LOCALES[lang];
|
|
277
|
-
const items: RenderItem[] = [];
|
|
278
|
-
|
|
279
|
-
if (snapshot && snapshot.periods.length > 0) {
|
|
280
|
-
items.push({ kind: "text", text: `${locale.title}: ` });
|
|
281
|
-
snapshot.periods.forEach((p, i) => {
|
|
282
|
-
if (i > 0) items.push({ kind: "text", text: " / " });
|
|
283
|
-
const label = locale.labels[p.label] ?? p.label;
|
|
284
|
-
items.push({ kind: "text", text: `${label} ` });
|
|
285
|
-
items.push({ kind: "pct", pct: clampPct(p.percent), metric: p.label });
|
|
286
|
-
const delta = deltas?.[p.label];
|
|
287
|
-
if (typeof delta === "number" && delta !== 0) {
|
|
288
|
-
const sign = delta > 0 ? "+" : "-";
|
|
289
|
-
items.push({ kind: "annotation", text: `(${sign}${Math.abs(Math.round(delta))})` });
|
|
290
|
-
}
|
|
291
|
-
if (p.reset_at) {
|
|
292
|
-
const remaining = new Date(p.reset_at).getTime() - Date.now();
|
|
293
|
-
const t = formatRemaining(remaining, lang);
|
|
294
|
-
if (t) items.push({ kind: "annotation", text: `(${locale.resetsIn(t)})` });
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
} else if (status === "fetching") {
|
|
298
|
-
items.push({ kind: "text", text: `${locale.title}: ` });
|
|
299
|
-
items.push({ kind: "annotation", text: locale.fetching });
|
|
300
|
-
} else if (status === "failed") {
|
|
301
|
-
items.push({ kind: "text", text: `${locale.title}: ` });
|
|
302
|
-
items.push({ kind: "annotation", text: locale.failed });
|
|
303
|
-
} else if (status === "no-binary") {
|
|
304
|
-
items.push({ kind: "annotation", text: locale.noBinary });
|
|
305
|
-
} else {
|
|
306
|
-
items.push({ kind: "annotation", text: locale.noData });
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (!isIdle) {
|
|
310
|
-
items.push({ kind: "annotation", text: ` (${locale.using})` });
|
|
311
|
-
} else if (note) {
|
|
312
|
-
items.push({ kind: "annotation", text: ` (${note === "reset" ? locale.reset : locale.changed})` });
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (snapshot) {
|
|
316
|
-
items.push({ kind: "age", text: formatAge(snapshot.fetchedAt, lang) });
|
|
317
|
-
}
|
|
318
|
-
return items;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function formatItems(items: RenderItem[], theme: Theme): string {
|
|
322
|
-
return items
|
|
323
|
-
.map((it) => {
|
|
324
|
-
switch (it.kind) {
|
|
325
|
-
case "text":
|
|
326
|
-
return theme.fg("dim", it.text);
|
|
327
|
-
case "pct":
|
|
328
|
-
return coloredPct(it.pct);
|
|
329
|
-
case "age":
|
|
330
|
-
return theme.fg("dim", it.text);
|
|
331
|
-
case "annotation":
|
|
332
|
-
default:
|
|
333
|
-
return hexFg(HEX_COLORS.purple, it.text);
|
|
334
|
-
}
|
|
335
|
-
})
|
|
336
|
-
.join("");
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/* ------------------------------ ansi helpers ------------------------------ */
|
|
340
|
-
|
|
341
|
-
export const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
342
|
-
|
|
343
|
-
export function visibleWidth(s: string): number {
|
|
344
|
-
let w = 0;
|
|
345
|
-
for (const ch of s.replace(ANSI_RE, "")) {
|
|
346
|
-
const cp = ch.codePointAt(0) ?? 0;
|
|
347
|
-
if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) continue;
|
|
348
|
-
if (cp >= 0x0300 && cp <= 0x036f) continue;
|
|
349
|
-
w +=
|
|
350
|
-
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
351
|
-
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
352
|
-
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
353
|
-
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
354
|
-
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
355
|
-
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
356
|
-
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
357
|
-
(cp >= 0x1f300 && cp <= 0x1f9ff)
|
|
358
|
-
? 2
|
|
359
|
-
: 1;
|
|
360
|
-
}
|
|
361
|
-
return w;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export function truncateAnsi(s: string, maxWidth: number): string {
|
|
365
|
-
if (maxWidth <= 0) return "";
|
|
366
|
-
if (visibleWidth(s) <= maxWidth) return s;
|
|
367
|
-
const budget = maxWidth - 1;
|
|
368
|
-
let out = "";
|
|
369
|
-
let w = 0;
|
|
370
|
-
let i = 0;
|
|
371
|
-
while (i < s.length) {
|
|
372
|
-
ANSI_RE.lastIndex = i;
|
|
373
|
-
const m = ANSI_RE.exec(s);
|
|
374
|
-
if (m && m.index === i) {
|
|
375
|
-
out += m[0];
|
|
376
|
-
i = ANSI_RE.lastIndex;
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
const ch = String.fromCodePoint(s.codePointAt(i) ?? 0);
|
|
380
|
-
const cw = visibleWidth(ch);
|
|
381
|
-
if (w + cw > budget) break;
|
|
382
|
-
out += ch;
|
|
383
|
-
w += cw;
|
|
384
|
-
i += ch.length;
|
|
385
|
-
}
|
|
386
|
-
return `${out}…\x1b[0m`;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
export function itemsEqual(a: RenderItem[], b: RenderItem[]): boolean {
|
|
390
|
-
if (a.length !== b.length) return false;
|
|
391
|
-
return a.every((it, i) => {
|
|
392
|
-
const o = b[i];
|
|
393
|
-
if (it.kind !== o.kind) return false;
|
|
394
|
-
switch (it.kind) {
|
|
395
|
-
case "pct":
|
|
396
|
-
return (
|
|
397
|
-
o.kind === "pct" && it.pct === o.pct && it.metric === o.metric
|
|
398
|
-
);
|
|
399
|
-
case "text":
|
|
400
|
-
return o.kind === "text" && it.text === o.text;
|
|
401
|
-
case "age":
|
|
402
|
-
return o.kind === "age" && it.text === o.text;
|
|
403
|
-
case "annotation":
|
|
404
|
-
return o.kind === "annotation" && it.text === o.text;
|
|
405
|
-
}
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/* -------------------------------- component -------------------------------- */
|
|
410
|
-
|
|
411
|
-
export interface Component {
|
|
412
|
-
render(width: number): string[];
|
|
413
|
-
invalidate(): void;
|
|
414
|
-
dispose?(): void;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Widget rendered below the editor. The age segment is right-aligned and the
|
|
419
|
-
* content wraps to two lines when the terminal is too narrow.
|
|
420
|
-
*/
|
|
421
|
-
export class ArkUsageComponent implements Component {
|
|
422
|
-
private items: RenderItem[];
|
|
423
|
-
private readonly themeRef: () => Theme;
|
|
424
|
-
private readonly requestRender: () => void;
|
|
425
|
-
private cache: { width: number; lines: string[] } | null = null;
|
|
426
|
-
private disposed = false;
|
|
427
|
-
|
|
428
|
-
constructor(
|
|
429
|
-
items: RenderItem[],
|
|
430
|
-
themeRef: () => Theme,
|
|
431
|
-
requestRender: () => void,
|
|
432
|
-
) {
|
|
433
|
-
this.items = items;
|
|
434
|
-
this.themeRef = themeRef;
|
|
435
|
-
this.requestRender = requestRender;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
update(items: RenderItem[]): void {
|
|
439
|
-
if (this.disposed || itemsEqual(this.items, items)) return;
|
|
440
|
-
this.items = items;
|
|
441
|
-
this.invalidate();
|
|
442
|
-
this.requestRender();
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
invalidate(): void {
|
|
446
|
-
this.cache = null;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
render(width: number): string[] {
|
|
450
|
-
if (this.cache && this.cache.width === width) return this.cache.lines;
|
|
451
|
-
const theme = this.themeRef();
|
|
452
|
-
|
|
453
|
-
const ageIdx = this.items.findIndex((it) => it.kind === "age");
|
|
454
|
-
if (ageIdx === -1) {
|
|
455
|
-
const text = formatItems(this.items, theme);
|
|
456
|
-
const lines = text ? [truncateAnsi(text, width)] : [];
|
|
457
|
-
this.cache = { width, lines };
|
|
458
|
-
return lines;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const leftText = formatItems(this.items.slice(0, ageIdx), theme);
|
|
462
|
-
const rightText = formatItems(this.items.slice(ageIdx), theme);
|
|
463
|
-
const lw = visibleWidth(leftText);
|
|
464
|
-
const rw = visibleWidth(rightText);
|
|
465
|
-
|
|
466
|
-
let lines: string[];
|
|
467
|
-
if (lw + rw + 1 <= width) {
|
|
468
|
-
lines = [leftText + " ".repeat(width - lw - rw) + rightText];
|
|
469
|
-
} else {
|
|
470
|
-
lines = [
|
|
471
|
-
leftText ? truncateAnsi(leftText, width) : "",
|
|
472
|
-
rw <= width ? " ".repeat(Math.max(0, width - rw)) + rightText : truncateAnsi(rightText, width),
|
|
473
|
-
].filter((l) => l !== "");
|
|
474
|
-
}
|
|
475
|
-
this.cache = { width, lines };
|
|
476
|
-
return lines;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
dispose(): void {
|
|
480
|
-
this.disposed = true;
|
|
481
|
-
this.cache = null;
|
|
482
|
-
}
|
|
483
|
-
}
|