pi-ark-usage 0.1.2 → 0.1.4

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
@@ -10,9 +10,8 @@ Volc Usage: sess 30%(reset after 4h18m) / wk 37%(reset after 4d) / mo 19%(reset
10
10
 
11
11
  - **AK/SK 直连 OpenAPI**:不走子进程、不依赖 SSO 登录态;凭据只从环境变量或配置文件读取,本扩展不保存、不上传任何 Key
12
12
  - **三个周期同屏**:session(会话)/ weekly(周)/ monthly(月),并显示各周期重置倒计时
13
- - **阈值着色**:低用量绿色、接近上限黄色、超过红线红色
14
- - **单轮消耗**:以每轮对话开始前的快照为基线,结算时标注各周期增量(如 `(+2)`)
15
- - **产品切换感知**:在个人版/团队版、Coding Plan/Agent Plan 之间切换时标注 `(变更)`;套餐周期重置时标注 `(已重置)`
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) 上;百分比按阈值绿/黄/红着色,时间部分紫色,分隔符跟随主题 `thinkingHigh`(编辑器边框色)。不安装 powerline 时也可通过内置 footer 的 extension statuses 区域或 `/arkusage` 查看
14
+ - **阈值着色**:低用量绿色(<50%)、接近上限黄色(50–79%)、超过红线红色(≥80%,默认值,可用 `/arkset` 调整)
16
15
  - **持久化缓存**:重启 pi 后立即显示上次快照(`~/.pi/agent/pi-ark-usage/cache.json`,0600 权限,临时文件原子写入)
17
16
  - 可选空闲自动刷新(默认关闭)
18
17
 
@@ -98,7 +97,7 @@ pi install npm:pi-ark-usage
98
97
  1. 用官方 `@volcengine/openapi` 的 `Signer` 对 `POST open.volcengineapi.com?Action=GetCodingPlanUsage&Version=2024-01-01`(service `ark`,region `cn-beijing`)做 V4 签名后 fetch。
99
98
  2. 解析 `Result.QuotaUsage[]`(`Level/Percent/ResetTimestamp`,秒级时间戳转 `+08:00` ISO 字符串),做结构与取值校验。
100
99
  3. 快照存入内存 Map,并以原子写(tmp 文件 + rename)持久化到 `~/.pi/agent/pi-ark-usage/`。
101
- 4. Widget 通过 `ctx.ui.setWidget(..., { placement: "belowEditor" })` 渲染在编辑器下方;age(抓取于多久前)右对齐,终端过窄时自动折成两行。
100
+ 4. 摘要通过 `ctx.ui.setStatus("ark-usage", …)` 发布到 extension status,由 pi-powerline-footer 渲染到 powerline 行;每分钟 ticker 在空闲时重发,保持重置倒计时刷新。
102
101
 
103
102
  ## 开发
104
103
 
@@ -6,14 +6,11 @@
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`): 1% 4h38m | 15% 5d4h | 54% 23d4h
10
10
  * Commands:
11
11
  * /arkcheck force refresh
12
12
  * /arkusage show snapshot in a notification
13
13
  * /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
14
  */
18
15
 
19
16
  import type {
@@ -23,10 +20,9 @@ import type {
23
20
  } from "@earendil-works/pi-coding-agent";
24
21
  import { fetchPlan } from "./lib/arkcli.js";
25
22
  import {
26
- ArkUsageComponent,
27
23
  AUTO_REFRESH_MAX_MINUTES,
28
24
  LOCALES,
29
- buildItems,
25
+ buildCompactStatus,
30
26
  formatRemaining,
31
27
  getSettings,
32
28
  patchSettings,
@@ -44,10 +40,10 @@ import type {
44
40
  ArkSettings,
45
41
  Language,
46
42
  PlanSnapshot,
47
- RenderItem,
48
43
  } from "./lib/types.js";
49
44
 
50
- const WIDGET_KEY = "pi-ark-usage";
45
+ /** Extension-status key consumed by pi-powerline-footer (powerline slot). */
46
+ const STATUS_KEY = "ark-usage";
51
47
  const TICK_MS = 60_000;
52
48
  const AGENT_START_TIMEOUT_MS = 3_000;
53
49
  const BASELINE_FRESH_MS = 60 * 60_000;
@@ -84,10 +80,6 @@ let language: Language = "en";
84
80
  const snapshots = new Map<string, PlanSnapshot>();
85
81
  let activeProduct: ProductId | null = null;
86
82
  let status: Status = "ok";
87
- let deltas: Record<string, number> | null = null;
88
- let note: "changed" | "reset" | null = null;
89
- let baseline: PlanSnapshot | null = null;
90
- let activeWidget: ArkUsageComponent | null = null;
91
83
  let tickTimer: ReturnType<typeof setInterval> | null = null;
92
84
  let lastCtx: ExtensionContext | null = null;
93
85
  let lastAutoAt = 0;
@@ -125,34 +117,19 @@ function activeSnapshot(): PlanSnapshot | null {
125
117
 
126
118
  /* -------------------------------- rendering -------------------------------- */
127
119
 
128
- function renderWidget(ctx: ExtensionContext): void {
120
+ function renderStatus(ctx: ExtensionContext): void {
129
121
  lastCtx = ctx;
130
122
  const snap = activeSnapshot();
131
- const items: RenderItem[] = buildItems(
132
- snap,
133
- language,
134
- status === "ok" && !snap ? "none" : status,
135
- ctx.isIdle(),
136
- deltas,
137
- note,
138
- );
139
- if (activeWidget) {
140
- activeWidget.update(items);
141
- return;
123
+ const widgetStatus = status === "ok" && !snap ? "none" : status;
124
+ // Publish the one-line summary to the extension-status slot (powerline).
125
+ // The ticker calls renderStatus every minute while idle, keeping the
126
+ // reset countdown fresh without refetching.
127
+ const compact = buildCompactStatus(snap, widgetStatus, ctx.ui.theme);
128
+ if (compact) {
129
+ ctx.ui.setStatus(STATUS_KEY, compact);
130
+ } else {
131
+ ctx.ui.setStatus(STATUS_KEY, undefined);
142
132
  }
143
- ctx.ui.setWidget(
144
- WIDGET_KEY,
145
- (tui, theme) => {
146
- const component = new ArkUsageComponent(
147
- items,
148
- () => theme,
149
- () => tui.requestRender?.(),
150
- );
151
- activeWidget = component;
152
- return component;
153
- },
154
- { placement: "belowEditor" },
155
- );
156
133
  }
157
134
 
158
135
  /* --------------------------------- fetching -------------------------------- */
@@ -180,7 +157,7 @@ async function refresh(
180
157
  activeProduct = product;
181
158
  if (!product) {
182
159
  status = "none";
183
- renderWidget(ctx);
160
+ renderStatus(ctx);
184
161
  return false;
185
162
  }
186
163
 
@@ -190,8 +167,7 @@ async function refresh(
190
167
  }
191
168
 
192
169
  status = "fetching";
193
- note = null;
194
- renderWidget(ctx);
170
+ renderStatus(ctx);
195
171
 
196
172
  const controller = new AbortController();
197
173
  inFlight = controller;
@@ -229,7 +205,7 @@ async function refresh(
229
205
  return false;
230
206
  } finally {
231
207
  if (inFlight === controller) inFlight = null;
232
- renderWidget(ctx);
208
+ renderStatus(ctx);
233
209
  }
234
210
  }
235
211
 
@@ -239,66 +215,21 @@ function persist(): void {
239
215
  saveCache(language, out);
240
216
  }
241
217
 
242
- /* ---------------------------- baseline / delta ----------------------------- */
243
-
244
- function computeDeltas(
245
- before: PlanSnapshot,
246
- after: PlanSnapshot,
247
- ): Record<string, number> | null {
248
- const out: Record<string, number> = {};
249
- for (const p of after.periods) {
250
- const old = before.periods.find((q) => q.label === p.label);
251
- if (old) out[p.label] = Math.round(old.percent - p.percent);
252
- // note: stored as consumed-positive; renderer negates for display
253
- }
254
- // flip sign convention for renderer: it wants negative=consumed
255
- const render: Record<string, number> = {};
256
- for (const [k, v] of Object.entries(out)) render[k] = -v;
257
- return render;
258
- }
259
-
260
- function looksReset(a: PlanSnapshot, b: PlanSnapshot): boolean {
261
- // all periods dropped sharply at once => plan period reset
262
- return b.periods.every((p) => {
263
- const old = a.periods.find((q) => q.label === p.label);
264
- return old !== undefined && p.percent - old.percent < -30;
265
- });
266
- }
218
+ /* ----------------------- session-start / settled --------------------------- */
267
219
 
268
220
  async function handleStart(ctx: ExtensionContext): Promise<void> {
269
221
  const product = resolveProduct(ctx);
270
222
  if (!product) return;
271
223
  const cached = snapshots.get(product) ?? null;
272
- if (cached && Date.now() - cached.fetchedAt <= BASELINE_FRESH_MS) {
273
- baseline = cached;
274
- } else {
275
- const win = await timeoutResult(refresh(ctx, "stale"), AGENT_START_TIMEOUT_MS);
276
- baseline = win ? snapshots.get(product) ?? null : cached;
224
+ // Only refetch at agent_start when the cached snapshot went stale.
225
+ if (!cached || Date.now() - cached.fetchedAt > BASELINE_FRESH_MS) {
226
+ await timeoutResult(refresh(ctx, "stale"), AGENT_START_TIMEOUT_MS);
277
227
  }
278
- deltas = null;
279
- renderWidget(ctx);
228
+ renderStatus(ctx);
280
229
  }
281
230
 
282
231
  async function handleSettled(ctx: ExtensionContext): Promise<void> {
283
- const product = resolveProduct(ctx);
284
- if (!product) return;
285
- const ok = await refresh(ctx, "settled");
286
- if (!ok) return;
287
- const snap = snapshots.get(product);
288
- if (!snap) return;
289
-
290
- if (baseline && baseline.product !== product) {
291
- note = "changed";
292
- deltas = null;
293
- } else if (baseline && looksReset(baseline, snap)) {
294
- note = "reset";
295
- deltas = null;
296
- } else if (baseline) {
297
- deltas = computeDeltas(baseline, snap);
298
- note = null;
299
- }
300
- baseline = null;
301
- renderWidget(ctx);
232
+ await refresh(ctx, "settled");
302
233
  }
303
234
 
304
235
  async function timeoutResult<T>(p: Promise<T>, ms: number): Promise<T | null> {
@@ -329,7 +260,7 @@ function ensureTicker(): void {
329
260
  return;
330
261
  }
331
262
  // age text ticks every minute even without refetch
332
- if (activeWidget && lastCtx.isIdle()) renderWidget(lastCtx);
263
+ if (lastCtx && lastCtx.isIdle()) renderStatus(lastCtx);
333
264
  }, TICK_MS);
334
265
  tickTimer.unref?.();
335
266
  }
@@ -395,7 +326,7 @@ async function cmdSet(
395
326
  const notifySaved = (): void => {
396
327
  persist();
397
328
  ctx.ui.notify(locale.settingsApplied, "info");
398
- if (lastCtx) renderWidget(lastCtx);
329
+ if (lastCtx) renderStatus(lastCtx);
399
330
  };
400
331
 
401
332
  if (parts.length === 0) {
@@ -409,7 +340,7 @@ async function cmdSet(
409
340
  resetSettings();
410
341
  persist();
411
342
  ctx.ui.notify(locale.settingsReset, "info");
412
- if (lastCtx) renderWidget(lastCtx);
343
+ if (lastCtx) renderStatus(lastCtx);
413
344
  return;
414
345
  }
415
346
  case "yellow":
@@ -486,7 +417,7 @@ async function cmdSet(
486
417
  language = raw;
487
418
  persist();
488
419
  ctx.ui.notify(locale.languageChanged(language), "info");
489
- if (lastCtx) renderWidget(lastCtx);
420
+ if (lastCtx) renderStatus(lastCtx);
490
421
  return;
491
422
  }
492
423
  default:
@@ -519,25 +450,21 @@ export default function (pi: ExtensionAPI): void {
519
450
  });
520
451
 
521
452
  pi.on("session_start", (_event, ctx) => {
522
- // Session switches reuse this module instance in-process: pi has disposed
523
- // the old TUI widget and torn down the previous runtime, so drop stale
524
- // handles and revive the shutdown flags before doing anything else.
453
+ // Session switches reuse this module instance in-process: pi has torn
454
+ // down the previous runtime, so revive the shutdown flags before doing
455
+ // anything else.
525
456
  shuttingDown = false;
526
457
  resetShutdownState();
527
- activeWidget = null;
528
458
  activeProduct = resolveProduct(ctx);
529
- deltas = null;
530
- note = null;
531
- renderWidget(ctx);
459
+ renderStatus(ctx);
532
460
  ensureTicker();
533
461
  void refresh(ctx, "session_start");
534
462
  });
535
463
 
536
464
  pi.on("model_select", (_event, ctx) => {
537
465
  const next = resolveProduct(ctx);
538
- if (baseline && next && baseline.product !== next) note = "changed";
539
466
  activeProduct = next;
540
- renderWidget(ctx);
467
+ renderStatus(ctx);
541
468
  void refresh(ctx, "model_select");
542
469
  });
543
470
 
@@ -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"}`,
@@ -122,7 +111,7 @@ export const LOCALES: Record<Language, Locale> = { zh: ZH, en: EN };
122
111
  export const AUTO_REFRESH_MAX_MINUTES = 30;
123
112
 
124
113
  export const DEFAULT_SETTINGS: ArkSettings = {
125
- pctYellow: 40,
114
+ pctYellow: 50,
126
115
  pctRed: 80,
127
116
  autoRefreshMinutes: 5,
128
117
  product: "auto",
@@ -196,237 +185,56 @@ 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);
188
+ /* ---------------------- compact status (powerline slot) -------------------- */
189
+
190
+ /**
191
+ * Compact reset countdown for the powerline status: `38m`, `4h38m`, `5d4h`.
192
+ * Lower-precision units are dropped to keep the line short and stable.
193
+ */
194
+ export function formatCompactRemaining(ms: number): string {
195
+ if (!Number.isFinite(ms) || ms <= 0) return "";
196
+ const totalMin = Math.floor(ms / 60_000);
197
+ const d = Math.floor(totalMin / 1440);
198
+ const h = Math.floor((totalMin % 1440) / 60);
205
199
  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);
200
+ if (d > 0) return `${d}d${h}h`;
201
+ if (h > 0) return `${h}h${m}m`;
202
+ return `${m}m`;
210
203
  }
211
204
 
212
- /* ----------------------------- snapshot -> items --------------------------- */
213
-
214
- export type WidgetStatus = "ok" | "fetching" | "failed" | "no-binary" | "none";
215
-
216
- export function buildItems(
205
+ /**
206
+ * One-line colored summary for the powerline extension-status slot:
207
+ * `1% 4h38m | 15% 5d4h | 54% 23d4h`
208
+ *
209
+ * Periods are shown in API order (session / weekly / monthly) without
210
+ * labels — the order itself is the convention. Percent follows the
211
+ * yellow/red thresholds (green below yellow, yellow below red, red at/above
212
+ * red); the time part is always purple; the ` | ` separator uses the
213
+ * editor border color (`thinkingHigh`, pink in the dark theme).
214
+ *
215
+ * Returns null when nothing should be shown (the powerline item hides).
216
+ */
217
+ export function buildCompactStatus(
217
218
  snapshot: PlanSnapshot | null,
218
- lang: Language,
219
219
  status: WidgetStatus,
220
- isIdle: boolean,
221
- /** delta annotations keyed by period label; negative = consumed */
222
- deltas: Record<string, number> | null,
223
- note?: "changed" | "reset" | null,
224
- ): RenderItem[] {
225
- const locale = LOCALES[lang];
226
- const items: RenderItem[] = [];
227
-
220
+ theme: Theme,
221
+ ): string | null {
228
222
  if (snapshot && snapshot.periods.length > 0) {
229
- items.push({ kind: "text", text: `${locale.title}: ` });
230
- snapshot.periods.forEach((p, i) => {
231
- if (i > 0) items.push({ kind: "text", text: " / " });
232
- const label = locale.labels[p.label] ?? p.label;
233
- items.push({ kind: "text", text: `${label} ` });
234
- items.push({ kind: "pct", pct: clampPct(p.percent), metric: p.label });
235
- const delta = deltas?.[p.label];
236
- if (typeof delta === "number" && delta !== 0) {
237
- const sign = delta > 0 ? "+" : "-";
238
- items.push({ kind: "annotation", text: `(${sign}${Math.abs(Math.round(delta))})` });
239
- }
240
- if (p.reset_at) {
241
- const remaining = new Date(p.reset_at).getTime() - Date.now();
242
- const t = formatRemaining(remaining, lang);
243
- if (t) items.push({ kind: "annotation", text: `(${locale.resetsIn(t)})` });
244
- }
245
- });
246
- } else if (status === "fetching") {
247
- items.push({ kind: "text", text: `${locale.title}: ` });
248
- items.push({ kind: "annotation", text: locale.fetching });
249
- } else if (status === "failed") {
250
- items.push({ kind: "text", text: `${locale.title}: ` });
251
- items.push({ kind: "annotation", text: locale.failed });
252
- } else if (status === "no-binary") {
253
- items.push({ kind: "annotation", text: locale.noBinary });
254
- } else {
255
- items.push({ kind: "annotation", text: locale.noData });
256
- }
257
-
258
- if (!isIdle) {
259
- items.push({ kind: "annotation", text: ` (${locale.using})` });
260
- } else if (note) {
261
- items.push({ kind: "annotation", text: ` (${note === "reset" ? locale.reset : locale.changed})` });
262
- }
263
-
264
- if (snapshot) {
265
- items.push({ kind: "age", text: formatAge(snapshot.fetchedAt, lang) });
266
- }
267
- return items;
268
- }
269
-
270
- export function formatItems(items: RenderItem[], theme: Theme): string {
271
- return items
272
- .map((it) => {
273
- switch (it.kind) {
274
- case "text":
275
- return theme.fg("dim", it.text);
276
- case "pct":
277
- return coloredPct(it.pct);
278
- case "age":
279
- return theme.fg("dim", it.text);
280
- case "annotation":
281
- default:
282
- return hexFg(HEX_COLORS.purple, it.text);
283
- }
284
- })
285
- .join("");
286
- }
287
-
288
- /* ------------------------------ ansi helpers ------------------------------ */
289
-
290
- export const ANSI_RE = /\x1b\[[0-9;]*m/g;
291
-
292
- export function visibleWidth(s: string): number {
293
- let w = 0;
294
- for (const ch of s.replace(ANSI_RE, "")) {
295
- const cp = ch.codePointAt(0) ?? 0;
296
- if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) continue;
297
- if (cp >= 0x0300 && cp <= 0x036f) continue;
298
- w +=
299
- (cp >= 0x1100 && cp <= 0x115f) ||
300
- (cp >= 0x2e80 && cp <= 0xa4cf) ||
301
- (cp >= 0xac00 && cp <= 0xd7a3) ||
302
- (cp >= 0xf900 && cp <= 0xfaff) ||
303
- (cp >= 0xfe30 && cp <= 0xfe6f) ||
304
- (cp >= 0xff00 && cp <= 0xff60) ||
305
- (cp >= 0xffe0 && cp <= 0xffe6) ||
306
- (cp >= 0x1f300 && cp <= 0x1f9ff)
307
- ? 2
308
- : 1;
223
+ const sep = theme.fg("thinkingHigh", " | ");
224
+ return snapshot.periods
225
+ .map((p) => {
226
+ const pct = coloredPct(clampPct(p.percent));
227
+ const reset = p.reset_at
228
+ ? formatCompactRemaining(new Date(p.reset_at).getTime() - Date.now())
229
+ : "";
230
+ return reset ? `${pct} ${hexFg(HEX_COLORS.purple, reset)}` : pct;
231
+ })
232
+ .join(sep);
309
233
  }
310
- return w;
234
+ if (status === "fetching") return hexFg(HEX_COLORS.purple, "…");
235
+ if (status === "failed") return hexFg(HEX_COLORS.red, "failed");
236
+ return null;
311
237
  }
312
238
 
313
- export function truncateAnsi(s: string, maxWidth: number): string {
314
- if (maxWidth <= 0) return "";
315
- if (visibleWidth(s) <= maxWidth) return s;
316
- const budget = maxWidth - 1;
317
- let out = "";
318
- let w = 0;
319
- let i = 0;
320
- while (i < s.length) {
321
- ANSI_RE.lastIndex = i;
322
- const m = ANSI_RE.exec(s);
323
- if (m && m.index === i) {
324
- out += m[0];
325
- i = ANSI_RE.lastIndex;
326
- continue;
327
- }
328
- const ch = String.fromCodePoint(s.codePointAt(i) ?? 0);
329
- const cw = visibleWidth(ch);
330
- if (w + cw > budget) break;
331
- out += ch;
332
- w += cw;
333
- i += ch.length;
334
- }
335
- return `${out}…\x1b[0m`;
336
- }
337
-
338
- export function itemsEqual(a: RenderItem[], b: RenderItem[]): boolean {
339
- if (a.length !== b.length) return false;
340
- return a.every((it, i) => {
341
- const o = b[i];
342
- if (it.kind !== o.kind) return false;
343
- switch (it.kind) {
344
- case "pct":
345
- return (
346
- o.kind === "pct" && it.pct === o.pct && it.metric === o.metric
347
- );
348
- case "text":
349
- return o.kind === "text" && it.text === o.text;
350
- case "age":
351
- return o.kind === "age" && it.text === o.text;
352
- case "annotation":
353
- return o.kind === "annotation" && it.text === o.text;
354
- }
355
- });
356
- }
357
-
358
- /* -------------------------------- component -------------------------------- */
359
-
360
- export interface Component {
361
- render(width: number): string[];
362
- invalidate(): void;
363
- dispose?(): void;
364
- }
365
-
366
- /**
367
- * Widget rendered below the editor. The age segment is right-aligned and the
368
- * content wraps to two lines when the terminal is too narrow.
369
- */
370
- export class ArkUsageComponent implements Component {
371
- private items: RenderItem[];
372
- private readonly themeRef: () => Theme;
373
- private readonly requestRender: () => void;
374
- private cache: { width: number; lines: string[] } | null = null;
375
- private disposed = false;
376
-
377
- constructor(
378
- items: RenderItem[],
379
- themeRef: () => Theme,
380
- requestRender: () => void,
381
- ) {
382
- this.items = items;
383
- this.themeRef = themeRef;
384
- this.requestRender = requestRender;
385
- }
386
-
387
- update(items: RenderItem[]): void {
388
- if (this.disposed || itemsEqual(this.items, items)) return;
389
- this.items = items;
390
- this.invalidate();
391
- this.requestRender();
392
- }
393
-
394
- invalidate(): void {
395
- this.cache = null;
396
- }
397
-
398
- render(width: number): string[] {
399
- if (this.cache && this.cache.width === width) return this.cache.lines;
400
- const theme = this.themeRef();
401
-
402
- const ageIdx = this.items.findIndex((it) => it.kind === "age");
403
- if (ageIdx === -1) {
404
- const text = formatItems(this.items, theme);
405
- const lines = text ? [truncateAnsi(text, width)] : [];
406
- this.cache = { width, lines };
407
- return lines;
408
- }
409
-
410
- const leftText = formatItems(this.items.slice(0, ageIdx), theme);
411
- const rightText = formatItems(this.items.slice(ageIdx), theme);
412
- const lw = visibleWidth(leftText);
413
- const rw = visibleWidth(rightText);
414
-
415
- let lines: string[];
416
- if (lw + rw + 1 <= width) {
417
- lines = [leftText + " ".repeat(width - lw - rw) + rightText];
418
- } else {
419
- lines = [
420
- leftText ? truncateAnsi(leftText, width) : "",
421
- rw <= width ? " ".repeat(Math.max(0, width - rw)) + rightText : truncateAnsi(rightText, width),
422
- ].filter((l) => l !== "");
423
- }
424
- this.cache = { width, lines };
425
- return lines;
426
- }
427
-
428
- dispose(): void {
429
- this.disposed = true;
430
- this.cache = null;
431
- }
432
- }
239
+ /** Lifecycle states mirrored in the compact status. */
240
+ export type WidgetStatus = "ok" | "fetching" | "failed" | "no-binary" | "none";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ark-usage",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Show Volcengine Ark Coding Plan / Agent Plan usage in the pi TUI (AK/SK OpenAPI)",
5
5
  "keywords": [
6
6
  "pi",