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 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
- Volc Usage: sess 30%(reset after 4h18m) / wk 37%(reset after 4d) / mo 19%(reset after 29d) now
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 状态段**:通过 `ctx.ui.setStatus("ark-usage", …)` 发布单行摘要(`1% 4h38m | 15% 5d4h | 54% 23d4h`,固定按 session/weekly/monthly 顺序),可直接显示在 [pi-powerline-footer](https://www.npmjs.com/package/pi-powerline-footer) 的 `extension_statuses` / customItems 上;百分比按阈值绿/黄/红着色,时间部分紫色,分隔符跟随主题 `thinkingHigh`(编辑器边框色)
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. Widget 通过 `ctx.ui.setWidget(..., { placement: "belowEditor" })` 渲染在编辑器下方;age(抓取于多久前)右对齐,终端过窄时自动折成两行。
101
+ 4. 摘要通过 `ctx.ui.setStatus("ark-usage", …)` 发布到 extension status,由 pi-powerline-footer 渲染到 powerline 行;每分钟 ticker 在空闲时重发,保持重置倒计时与「上次刷新」时长显示为最新。
103
102
 
104
103
  ## 开发
105
104
 
@@ -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
- * Widget (below editor): 火山用量: 会话 4%(...) / 周 34%(...) / 月 17%(...)
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 renderWidget(ctx: ExtensionContext): void {
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 renderWidget every minute while idle, keeping the
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
- renderWidget(ctx);
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
- note = null;
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
- renderWidget(ctx);
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
- /* ---------------------------- baseline / delta ----------------------------- */
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
- if (cached && Date.now() - cached.fetchedAt <= BASELINE_FRESH_MS) {
286
- baseline = cached;
287
- } else {
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
- deltas = null;
292
- renderWidget(ctx);
230
+ renderStatus(ctx);
293
231
  }
294
232
 
295
233
  async function handleSettled(ctx: ExtensionContext): Promise<void> {
296
- const product = resolveProduct(ctx);
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 (activeWidget && lastCtx.isIdle()) renderWidget(lastCtx);
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) renderWidget(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) renderWidget(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) renderWidget(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 disposed
536
- // the old TUI widget and torn down the previous runtime, so drop stale
537
- // handles and revive the shutdown flags before doing anything else.
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
- deltas = null;
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
- renderWidget(ctx);
469
+ renderStatus(ctx);
554
470
  void refresh(ctx, "model_select");
555
471
  });
556
472
 
@@ -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 */
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Rendering: locales, threshold settings, percent coloring, remaining-time
3
- * formatting, and the TUI Component for the below-editor widget.
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, RenderItem } from "./types.js";
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
- return snapshot.periods
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() - Date.now())
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
- /* ----------------------------- snapshot -> items --------------------------- */
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ark-usage",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Show Volcengine Ark Coding Plan / Agent Plan usage in the pi TUI (AK/SK OpenAPI)",
5
5
  "keywords": [
6
6
  "pi",