@zhushanwen/pi-statusline 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-statusline",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi statusline extension — shows context usage, token speed, and provider quota in the footer.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,12 +22,12 @@
22
22
  "index.ts"
23
23
  ],
24
24
  "dependencies": {
25
- "@zhushanwen/pi-quota-providers": "0.4.0"
25
+ "@zhushanwen/pi-quota-providers": "0.4.1"
26
26
  },
27
27
  "peerDependencies": {
28
- "@mariozechner/pi-coding-agent": "*",
28
+ "@earendil-works/pi-ai": "*",
29
29
  "@earendil-works/pi-tui": "*",
30
- "@earendil-works/pi-ai": "*"
30
+ "@mariozechner/pi-coding-agent": "*"
31
31
  },
32
32
  "peerDependenciesMeta": {
33
33
  "@earendil-works/pi-tui": {
@@ -37,7 +37,12 @@
37
37
  "optional": true
38
38
  }
39
39
  },
40
+ "devDependencies": {
41
+ "vitest": "^4.1.8"
42
+ },
40
43
  "scripts": {
41
- "typecheck": "npx tsc --noEmit"
44
+ "typecheck": "npx tsc --noEmit",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
42
47
  }
43
48
  }
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Statusline 渲染逻辑测试
3
+ *
4
+ * 分两部分:
5
+ * 1. 真实数据验证:用实际 provider normalize 输出验证对齐和格式
6
+ * 2. Mock 回归测试:用固定数据防止未来格式变化破坏对齐
7
+ */
8
+
9
+ import { describe, it, expect } from "vitest";
10
+ import { INFINITE_WIN } from "@zhushanwen/pi-quota-providers";
11
+ import type { QuotaWindow, QuotaProvider, NormalizedQuotaRow } from "@zhushanwen/pi-quota-providers";
12
+ import {
13
+ formatWinCol,
14
+ buildTokenPlanLines,
15
+ buildSearchLine,
16
+ normalizeRows,
17
+ formatSpeedPart,
18
+ splitPath,
19
+ tailSessionId,
20
+ fmtResetSec,
21
+ fmtDuration,
22
+ fmtTokens,
23
+ fmtCount,
24
+ pctColor,
25
+ plainPallet,
26
+ plainThemeFg,
27
+ } from "../format.js";
28
+
29
+ // ── 辅助:从渲染文本中提取 `·` 的位置 ─────────────────
30
+
31
+ function dotPositions(line: string): number[] {
32
+ return [...line.matchAll(/·/g)].map((m) => m.index);
33
+ }
34
+
35
+ // ════════════════════════════════════════════════════════
36
+ // 1. 基础格式化函数
37
+ // ════════════════════════════════════════════════════════
38
+
39
+ describe("fmtResetSec", () => {
40
+ it("0 或负数返回空串", () => {
41
+ expect(fmtResetSec(0)).toBe("");
42
+ expect(fmtResetSec(-1)).toBe("");
43
+ });
44
+
45
+ it("分钟级:90s → 1m", () => {
46
+ expect(fmtResetSec(90)).toBe("1m");
47
+ });
48
+
49
+ it("小时级:3661s → 1h1m", () => {
50
+ expect(fmtResetSec(3661)).toBe("1h1m");
51
+ });
52
+
53
+ it("天级:90061s → 1d1h", () => {
54
+ expect(fmtResetSec(90061)).toBe("1d1h");
55
+ });
56
+ });
57
+
58
+ describe("fmtDuration", () => {
59
+ it("秒级", () => expect(fmtDuration(5000)).toBe("5s"));
60
+ it("分钟级", () => expect(fmtDuration(65000)).toBe("1m05s"));
61
+ it("小时级", () => expect(fmtDuration(3660000)).toBe("1h01m"));
62
+ });
63
+
64
+ describe("fmtTokens", () => {
65
+ it("< 1K", () => expect(fmtTokens(999)).toBe("999"));
66
+ it("1K+", () => expect(fmtTokens(1500)).toBe("1.5K"));
67
+ it("1M+", () => expect(fmtTokens(1_500_000)).toBe("1.5M"));
68
+ });
69
+
70
+ describe("fmtCount", () => {
71
+ it("< 1K", () => expect(fmtCount(500)).toBe("500"));
72
+ it("1K+", () => expect(fmtCount(1500)).toBe("1.5k"));
73
+ });
74
+
75
+ // ════════════════════════════════════════════════════════
76
+ // 1b. pctColor — 颜色边界值
77
+ // ════════════════════════════════════════════════════════
78
+
79
+ describe("pctColor", () => {
80
+ it("< 40% → success", () => expect(pctColor(0)).toBe("success"));
81
+ it("39% → success", () => expect(pctColor(39)).toBe("success"));
82
+ it("40% → accent", () => expect(pctColor(40)).toBe("accent"));
83
+ it("59% → accent", () => expect(pctColor(59)).toBe("accent"));
84
+ it("60% → warning", () => expect(pctColor(60)).toBe("warning"));
85
+ it("79% → warning", () => expect(pctColor(79)).toBe("warning"));
86
+ it("80% → error", () => expect(pctColor(80)).toBe("error"));
87
+ it("100% → error", () => expect(pctColor(100)).toBe("error"));
88
+ });
89
+
90
+ // ════════════════════════════════════════════════════════
91
+ // 1c. 路径工具
92
+ // ════════════════════════════════════════════════════════
93
+
94
+ describe("splitPath", () => {
95
+ it("标准绝对路径", () => {
96
+ expect(splitPath("/Users/foo/project")).toEqual(["Users", "foo", "project"]);
97
+ });
98
+
99
+ it("相对路径", () => {
100
+ expect(splitPath("foo/bar")).toEqual(["foo", "bar"]);
101
+ });
102
+
103
+ it("空串", () => {
104
+ expect(splitPath("")).toEqual([]);
105
+ });
106
+
107
+ it("尾部分隔符", () => {
108
+ expect(splitPath("/foo/bar/")).toEqual(["foo", "bar"]);
109
+ });
110
+ });
111
+
112
+ describe("tailSessionId", () => {
113
+ it("正常路径截取末尾 12 字符", () => {
114
+ // abc123def456.json → pop → abc123def456.json → slice(-12) → 3def456.json
115
+ expect(tailSessionId("/path/to/abc123def456.json", 12)).toBe("3def456.json");
116
+ });
117
+
118
+ it("undefined 返回空串", () => {
119
+ expect(tailSessionId(undefined, 12)).toBe("");
120
+ });
121
+
122
+ it("空串返回空串", () => {
123
+ expect(tailSessionId("", 12)).toBe("");
124
+ });
125
+
126
+ it("路径短于 n 字符时返回全名", () => {
127
+ expect(tailSessionId("file.json", 12)).toBe("file.json");
128
+ });
129
+ });
130
+
131
+ // ════════════════════════════════════════════════════════
132
+ // 2. formatWinCol — 列对齐核心
133
+ // ════════════════════════════════════════════════════════
134
+
135
+ describe("formatWinCol", () => {
136
+ it("有限百分比 + 有 reset", () => {
137
+ const result = formatWinCol("5h", { pct: 7, resetSec: 120 }, plainPallet, plainThemeFg);
138
+ expect(result).toBe("5h 7% 2m");
139
+ });
140
+
141
+ it("有限百分比 + 无 reset", () => {
142
+ const result = formatWinCol("wk", { pct: 42, resetSec: null }, plainPallet, plainThemeFg);
143
+ // reset 列补 RESET_COL_W 空格
144
+ expect(result).toBe("wk 42% ");
145
+ });
146
+
147
+ it("无限(pct=null)", () => {
148
+ const result = formatWinCol("mh", INFINITE_WIN, plainPallet, plainThemeFg);
149
+ // ∞ padStart(4) 右对齐 + " " + "--" padStart(7)
150
+ expect(result).toBe("mh ∞ --");
151
+ });
152
+
153
+ it("100% 满格", () => {
154
+ const result = formatWinCol("5h", { pct: 100, resetSec: 1800 }, plainPallet, plainThemeFg);
155
+ expect(result).toBe("5h 100% 30m");
156
+ });
157
+
158
+ it("0%", () => {
159
+ const result = formatWinCol("5h", { pct: 0, resetSec: 5400 }, plainPallet, plainThemeFg);
160
+ expect(result).toBe("5h 0% 1h30m");
161
+ });
162
+
163
+ it("∞ 列和有限列总字符宽度一致", () => {
164
+ const infinite = formatWinCol("5h", INFINITE_WIN, plainPallet, plainThemeFg);
165
+ const finite = formatWinCol("5h", { pct: 7, resetSec: 120 }, plainPallet, plainThemeFg);
166
+ expect(infinite.length).toBe(finite.length);
167
+ });
168
+
169
+ it("resetSec=0 与 resetSec=null 同宽", () => {
170
+ const zero = formatWinCol("5h", { pct: 50, resetSec: 0 }, plainPallet, plainThemeFg);
171
+ const nul = formatWinCol("5h", { pct: 50, resetSec: null }, plainPallet, plainThemeFg);
172
+ expect(zero.length).toBe(nul.length);
173
+ expect(zero).toBe(nul); // 都是空格填充
174
+ });
175
+
176
+ it("浮点 pct 四舍五入", () => {
177
+ const down = formatWinCol("5h", { pct: 7.4, resetSec: 0 }, plainPallet, plainThemeFg);
178
+ const up = formatWinCol("5h", { pct: 7.5, resetSec: 0 }, plainPallet, plainThemeFg);
179
+ expect(down).toContain(" 7%");
180
+ expect(up).toContain(" 8%");
181
+ });
182
+ });
183
+
184
+ // ════════════════════════════════════════════════════════
185
+ // 2b. formatWinCol 边界场景补充
186
+ // ════════════════════════════════════════════════════════
187
+
188
+ describe("formatWinCol 边界", () => {
189
+ it("resetSec=0 与 resetSec=null 完全相同(都填空格)", () => {
190
+ const zero = formatWinCol("5h", { pct: 50, resetSec: 0 }, plainPallet, plainThemeFg);
191
+ const nul = formatWinCol("5h", { pct: 50, resetSec: null }, plainPallet, plainThemeFg);
192
+ expect(zero).toBe(nul);
193
+ });
194
+
195
+ it("浮点 pct 四舍五入", () => {
196
+ const down = formatWinCol("5h", { pct: 7.4, resetSec: 0 }, plainPallet, plainThemeFg);
197
+ const up = formatWinCol("5h", { pct: 7.5, resetSec: 0 }, plainPallet, plainThemeFg);
198
+ expect(down).toContain(" 7%");
199
+ expect(up).toContain(" 8%");
200
+ });
201
+
202
+ it("负数 pct 原样显示", () => {
203
+ const result = formatWinCol("5h", { pct: -1, resetSec: 0 }, plainPallet, plainThemeFg);
204
+ expect(result).toContain("-1%");
205
+ });
206
+ });
207
+
208
+ // ════════════════════════════════════════════════════════
209
+ // 3b. buildTokenPlanLines 空数据
210
+ // ════════════════════════════════════════════════════════
211
+
212
+ describe("buildTokenPlanLines 空数据", () => {
213
+ it("空 cache 返回空数组", () => {
214
+ const providers: QuotaProvider[] = [{
215
+ id: "test", label: "test", category: "token-plan",
216
+ fetch: async () => null, normalize: () => ({ label: "test", wins: [INFINITE_WIN, INFINITE_WIN, INFINITE_WIN] }),
217
+ }];
218
+ expect(buildTokenPlanLines({}, providers, plainPallet, plainThemeFg)).toEqual([]);
219
+ });
220
+
221
+ it("空 providers 返回空数组", () => {
222
+ expect(buildTokenPlanLines({ test: {} }, [], plainPallet, plainThemeFg)).toEqual([]);
223
+ });
224
+
225
+ it("search-tool 类型的 provider 不出现", () => {
226
+ const providers: QuotaProvider[] = [{
227
+ id: "tavily", label: "tavily", category: "search-tool",
228
+ fetch: async () => null, normalize: () => null,
229
+ }];
230
+ expect(buildTokenPlanLines({ tavily: {} }, providers, plainPallet, plainThemeFg)).toEqual([]);
231
+ });
232
+
233
+ it("单行全 ∞ 对齐不变", () => {
234
+ const providers: QuotaProvider[] = [{
235
+ id: "test", label: "test-plan", category: "token-plan",
236
+ fetch: async () => null, normalize: () => ({ label: "t", wins: [INFINITE_WIN, INFINITE_WIN, INFINITE_WIN] }),
237
+ }];
238
+ const lines = buildTokenPlanLines({ test: {} }, providers, plainPallet, plainThemeFg);
239
+ expect(lines).toHaveLength(1);
240
+ const dots = dotPositions(lines[0]!);
241
+ expect(dots).toHaveLength(2);
242
+ expect(dots[0]).toBe(dots[1]! - 20); // 每个 cell 格式一致
243
+ });
244
+
245
+ it("超长 label 会撑宽但同长 label 间对齐", () => {
246
+ const providers: QuotaProvider[] = [
247
+ { id: "a", label: "short", category: "token-plan", fetch: async () => null,
248
+ normalize: () => ({ label: "s", wins: [{ pct: 10, resetSec: 100 }, { pct: 20, resetSec: 200 }, { pct: 30, resetSec: 300 }] }) },
249
+ { id: "b", label: "another-one", category: "token-plan", fetch: async () => null,
250
+ normalize: () => ({ label: "v", wins: [{ pct: 50, resetSec: 0 }, INFINITE_WIN, INFINITE_WIN] }) },
251
+ ];
252
+ const lines = buildTokenPlanLines({ a: {}, b: {} }, providers, plainPallet, plainThemeFg);
253
+ expect(lines).toHaveLength(2);
254
+ // 两个 label 都 <= 19,padEnd(19) 保证等宽
255
+ expect(lines[0]!.length).toBe(lines[1]!.length);
256
+ });
257
+ });
258
+
259
+ // ════════════════════════════════════════════════════════
260
+ // 3. buildTokenPlanLines — 整行渲染和列对齐
261
+ // ════════════════════════════════════════════════════════
262
+
263
+ describe("buildTokenPlanLines — 真实数据对齐验证", () => {
264
+ /** 从截图推断的真实 provider normalize 输出 */
265
+ const realProviders: QuotaProvider[] = [
266
+ {
267
+ id: "zhipu",
268
+ label: "zhipu-coding-plan",
269
+ category: "token-plan",
270
+ fetch: async () => null,
271
+ normalize(): NormalizedQuotaRow {
272
+ return {
273
+ label: "Z.ai-pro",
274
+ wins: [{ pct: 4, resetSec: 15660 }, INFINITE_WIN, INFINITE_WIN],
275
+ };
276
+ },
277
+ },
278
+ {
279
+ id: "opencode-go",
280
+ label: "opencode-go",
281
+ category: "token-plan",
282
+ fetch: async () => null,
283
+ normalize(): NormalizedQuotaRow {
284
+ return {
285
+ label: "opencode-go",
286
+ wins: [
287
+ { pct: 7, resetSec: 120 },
288
+ { pct: 42, resetSec: 396000 },
289
+ { pct: 71, resetSec: 1872600 },
290
+ ],
291
+ };
292
+ },
293
+ },
294
+ {
295
+ id: "kimi-coding",
296
+ label: "kimi-coding-plan",
297
+ category: "token-plan",
298
+ fetch: async () => null,
299
+ normalize(): NormalizedQuotaRow {
300
+ return {
301
+ label: "kimi-coding",
302
+ wins: [
303
+ { pct: 0, resetSec: 2280 },
304
+ { pct: 70, resetSec: 52680 },
305
+ INFINITE_WIN,
306
+ ],
307
+ };
308
+ },
309
+ },
310
+ {
311
+ id: "minimax",
312
+ label: "minimax-token-plan",
313
+ category: "token-plan",
314
+ fetch: async () => null,
315
+ normalize(): NormalizedQuotaRow {
316
+ return {
317
+ label: "minimax-token",
318
+ wins: [
319
+ { pct: 98, resetSec: 2220 },
320
+ { pct: 10, resetSec: 361440 },
321
+ INFINITE_WIN,
322
+ ],
323
+ };
324
+ },
325
+ },
326
+ ];
327
+
328
+ // 用假 cache 数据(只要 key 存在就行,normalize 不读 raw)
329
+ const cache: Record<string, unknown> = {
330
+ zhipu: { dummy: true },
331
+ "opencode-go": { dummy: true },
332
+ "kimi-coding": { dummy: true },
333
+ minimax: { dummy: true },
334
+ };
335
+
336
+ it("所有行使用 providers.json 的 label(非 normalize 返回的 label)", () => {
337
+ const lines = buildTokenPlanLines(cache, realProviders, plainPallet, plainThemeFg);
338
+ expect(lines[0]).toMatch(/^zhipu-coding-plan\s/);
339
+ expect(lines[1]).toMatch(/^opencode-go\s/);
340
+ expect(lines[2]).toMatch(/^kimi-coding-plan\s/);
341
+ expect(lines[3]).toMatch(/^minimax-token-plan\s/);
342
+ });
343
+
344
+ it("所有行的 `·` 分隔符位置一致", () => {
345
+ const lines = buildTokenPlanLines(cache, realProviders, plainPallet, plainThemeFg);
346
+ const positions = lines.map(dotPositions);
347
+ // 所有行的 · 位置应该相同
348
+ for (let i = 1; i < positions.length; i++) {
349
+ expect(positions[i], `Row ${i} dot positions mismatch`).toEqual(positions[0]);
350
+ }
351
+ });
352
+
353
+ it("所有行总长度一致(ASCII plain 模式下)", () => {
354
+ const lines = buildTokenPlanLines(cache, realProviders, plainPallet, plainThemeFg);
355
+ const lengths = lines.map((l) => l.length);
356
+ for (let i = 1; i < lengths.length; i++) {
357
+ expect(lengths[i], `Row ${i} length ${lengths[i]} !== Row 0 length ${lengths[0]}`).toBe(lengths[0]);
358
+ }
359
+ });
360
+
361
+ it("输出快照", () => {
362
+ const lines = buildTokenPlanLines(cache, realProviders, plainPallet, plainThemeFg);
363
+ for (const line of lines) {
364
+ // 确保无 ANSI 转义
365
+ expect(line).not.toMatch(/\x1b\[/);
366
+ }
367
+ // 肉眼可检查的快照
368
+ expect(lines).toMatchInlineSnapshot(`
369
+ [
370
+ "zhipu-coding-plan 5h 4% 4h21m · wk ∞ -- · mh ∞ --",
371
+ "opencode-go 5h 7% 2m · wk 42% 4d14h · mh 71% 21d16h",
372
+ "kimi-coding-plan 5h 0% 38m · wk 70% 14h38m · mh ∞ --",
373
+ "minimax-token-plan 5h 98% 37m · wk 10% 4d4h · mh ∞ --",
374
+ ]
375
+ `);
376
+ });
377
+ });
378
+
379
+ // ════════════════════════════════════════════════════════
380
+ // 4. buildSearchLine — Tavily 显示
381
+ // ════════════════════════════════════════════════════════
382
+
383
+ describe("buildSearchLine", () => {
384
+ const searchProviders: QuotaProvider[] = [
385
+ {
386
+ id: "tavily",
387
+ label: "tavily",
388
+ category: "search-tool",
389
+ fetch: async () => null,
390
+ normalize: () => null,
391
+ },
392
+ ];
393
+
394
+ it("有 planUsage/planLimit 时优先使用(API 调用次数)", () => {
395
+ const cache = {
396
+ tavily: { planUsage: 892, planLimit: 5000, available: 4, total: 5 },
397
+ };
398
+ const result = buildSearchLine(cache, searchProviders, plainPallet, plainThemeFg);
399
+ expect(result).toContain("892");
400
+ expect(result).toContain("5000");
401
+ expect(result).toContain("18%");
402
+ expect(result).not.toContain("4/5");
403
+ });
404
+
405
+ it("无 planUsage/planLimit 时 fallback 到 available/total(key 数量)", () => {
406
+ const cache = {
407
+ tavily: { available: 4, total: 5 },
408
+ };
409
+ const result = buildSearchLine(cache, searchProviders, plainPallet, plainThemeFg);
410
+ expect(result).toContain("4/5");
411
+ expect(result).toContain("80%");
412
+ });
413
+
414
+ it("total <= 0 时不显示", () => {
415
+ const cache = {
416
+ tavily: { planUsage: 0, planLimit: 0, available: 4, total: 0 },
417
+ };
418
+ const result = buildSearchLine(cache, searchProviders, plainPallet, plainThemeFg);
419
+ expect(result).toBe("");
420
+ });
421
+
422
+ it("无数据时不显示", () => {
423
+ const cache = {};
424
+ const result = buildSearchLine(cache, searchProviders, plainPallet, plainThemeFg);
425
+ expect(result).toBe("");
426
+ });
427
+
428
+ it("百分比计算精度(used=1, total=3 → 33%)", () => {
429
+ const cache = { tavily: { planUsage: 1, planLimit: 3 } };
430
+ const result = buildSearchLine(cache, searchProviders, plainPallet, plainThemeFg);
431
+ expect(result).toContain("33%");
432
+ });
433
+
434
+ it("多 search-tool 用 | 分隔", () => {
435
+ const providers: QuotaProvider[] = [
436
+ { id: "tavily", label: "tavily", category: "search-tool", fetch: async () => null, normalize: () => null },
437
+ { id: "other", label: "other", category: "search-tool", fetch: async () => null, normalize: () => null },
438
+ ];
439
+ const cache = {
440
+ tavily: { planUsage: 100, planLimit: 1000 },
441
+ other: { planUsage: 50, planLimit: 500 },
442
+ };
443
+ const result = buildSearchLine(cache, providers, plainPallet, plainThemeFg);
444
+ expect(result).toContain(" | ");
445
+ expect(result).toContain("tavily");
446
+ expect(result).toContain("other");
447
+ });
448
+
449
+ it("used=0 时仍显示", () => {
450
+ const cache = { tavily: { planUsage: 0, planLimit: 5000 } };
451
+ const result = buildSearchLine(cache, searchProviders, plainPallet, plainThemeFg);
452
+ expect(result).toContain("0/5000");
453
+ expect(result).toContain("0%");
454
+ });
455
+ });
456
+
457
+ // ════════════════════════════════════════════════════════
458
+ // 5. normalizeRows — label 优先级
459
+ // ════════════════════════════════════════════════════════
460
+
461
+ describe("normalizeRows", () => {
462
+ it("优先使用 provider.label(来自 providers.json)", () => {
463
+ const providers: QuotaProvider[] = [{
464
+ id: "test",
465
+ label: "configured-label",
466
+ category: "token-plan",
467
+ fetch: async () => null,
468
+ normalize: () => ({ label: "dynamic-label", wins: [INFINITE_WIN, INFINITE_WIN, INFINITE_WIN] }),
469
+ }];
470
+ const rows = normalizeRows({ test: {} }, providers);
471
+ expect(rows[0]?.name).toBe("configured-label");
472
+ });
473
+
474
+ it("provider.label 缺失时 fallback 到 normalize 返回的 label", () => {
475
+ const providers: QuotaProvider[] = [{
476
+ id: "test",
477
+ label: "",
478
+ category: "token-plan",
479
+ fetch: async () => null,
480
+ normalize: () => ({ label: "dynamic-label", wins: [INFINITE_WIN, INFINITE_WIN, INFINITE_WIN] }),
481
+ }];
482
+ const rows = normalizeRows({ test: {} }, providers);
483
+ expect(rows[0]?.name).toBe("dynamic-label");
484
+ });
485
+
486
+ it("normalize 返回 null 时跳过该 provider", () => {
487
+ const providers: QuotaProvider[] = [{
488
+ id: "test",
489
+ label: "test",
490
+ category: "token-plan",
491
+ fetch: async () => null,
492
+ normalize: () => null,
493
+ }];
494
+ const rows = normalizeRows({ test: {} }, providers);
495
+ expect(rows).toHaveLength(0);
496
+ });
497
+
498
+ it("normalize 抛异常时不影响其他 provider", () => {
499
+ const providers: QuotaProvider[] = [
500
+ {
501
+ id: "bad",
502
+ label: "bad",
503
+ category: "token-plan",
504
+ fetch: async () => null,
505
+ normalize: () => { throw new Error("boom"); },
506
+ },
507
+ {
508
+ id: "good",
509
+ label: "good",
510
+ category: "token-plan",
511
+ fetch: async () => null,
512
+ normalize: () => ({ label: "good", wins: [INFINITE_WIN, INFINITE_WIN, INFINITE_WIN] }),
513
+ },
514
+ ];
515
+ const rows = normalizeRows({ bad: {}, good: {} }, providers);
516
+ expect(rows).toHaveLength(1);
517
+ expect(rows[0]?.name).toBe("good");
518
+ });
519
+
520
+ it("search-tool 类型的 provider 被过滤", () => {
521
+ const providers: QuotaProvider[] = [{
522
+ id: "tavily", label: "tavily", category: "search-tool",
523
+ fetch: async () => null,
524
+ normalize: () => ({ label: "tavily", wins: [INFINITE_WIN, INFINITE_WIN, INFINITE_WIN] }),
525
+ }];
526
+ const rows = normalizeRows({ tavily: {} }, providers);
527
+ expect(rows).toHaveLength(0);
528
+ });
529
+
530
+ it("cache 中无对应 key 时跳过", () => {
531
+ const providers: QuotaProvider[] = [{
532
+ id: "test", label: "test", category: "token-plan",
533
+ fetch: async () => null,
534
+ normalize: () => ({ label: "test", wins: [INFINITE_WIN, INFINITE_WIN, INFINITE_WIN] }),
535
+ }];
536
+ const rows = normalizeRows({ other: {} }, providers);
537
+ expect(rows).toHaveLength(0);
538
+ });
539
+ });
540
+
541
+ // ════════════════════════════════════════════════════════
542
+ // 6. Mock 数据回归测试 — 边界场景
543
+ // ════════════════════════════════════════════════════════
544
+
545
+ describe("回归:边界百分比和 reset 时间对齐", () => {
546
+ const p = plainPallet;
547
+ const fg = plainThemeFg;
548
+
549
+ it("pct=0% 和 pct=100% 与 ∞ 对齐", () => {
550
+ const zero = formatWinCol("5h", { pct: 0, resetSec: 0 }, p, fg);
551
+ const full = formatWinCol("5h", { pct: 100, resetSec: 0 }, p, fg);
552
+ const inf = formatWinCol("5h", INFINITE_WIN, p, fg);
553
+ expect(zero.length).toBe(full.length);
554
+ expect(zero.length).toBe(inf.length);
555
+ });
556
+
557
+ it("resetSec=null 与有 resetSec 对齐", () => {
558
+ const noReset = formatWinCol("wk", { pct: 50, resetSec: null }, p, fg);
559
+ const hasReset = formatWinCol("wk", { pct: 50, resetSec: 3600 }, p, fg);
560
+ expect(noReset.length).toBe(hasReset.length);
561
+ });
562
+
563
+ it("多行组合对齐:全 ∞ / 全有限 / 混合", () => {
564
+ const scenarios: Array<{ pct: number | null; resetSec: number | null }> = [
565
+ { pct: null, resetSec: null },
566
+ { pct: 0, resetSec: 30 },
567
+ { pct: 50, resetSec: 3600 },
568
+ { pct: 99, resetSec: 86400 },
569
+ { pct: 100, resetSec: null },
570
+ ];
571
+ const results = scenarios.map((w) =>
572
+ formatWinCol("5h", w as QuotaWindow, p, fg),
573
+ );
574
+ const lengths = results.map((r) => r.length);
575
+ for (let i = 1; i < lengths.length; i++) {
576
+ expect(lengths[i], `scenario ${i} length mismatch`).toBe(lengths[0]);
577
+ }
578
+ });
579
+ });
580
+
581
+ // ════════════════════════════════════════════════════════
582
+ // 7. formatSpeedPart — buildLine2 速度部分
583
+ // ════════════════════════════════════════════════════════
584
+
585
+ describe("formatSpeedPart", () => {
586
+ it("current + day 都有", () => {
587
+ const result = formatSpeedPart({ current: 127, day: 85 }, plainPallet);
588
+ expect(result).toBe("│ speed 127t/s · day 85t/s");
589
+ });
590
+
591
+ it("只有 current", () => {
592
+ const result = formatSpeedPart({ current: 50, day: 0 }, plainPallet);
593
+ expect(result).toBe("│ speed 50t/s");
594
+ });
595
+
596
+ it("只有 day", () => {
597
+ const result = formatSpeedPart({ current: 0, day: 30 }, plainPallet);
598
+ expect(result).toBe("│ speed day 30t/s");
599
+ });
600
+
601
+ it("都为 0 时返回空串", () => {
602
+ expect(formatSpeedPart({ current: 0, day: 0 }, plainPallet)).toBe("");
603
+ });
604
+
605
+ it("负数不显示", () => {
606
+ expect(formatSpeedPart({ current: -1, day: -1 }, plainPallet)).toBe("");
607
+ });
608
+ });
package/src/format.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Statusline 纯格式化函数
3
+ *
4
+ * 从 index.ts 提取的可测试纯函数。
5
+ * 不依赖 Pi 运行时(ExtensionAPI / Theme),只做数据→字符串转换。
6
+ */
7
+
8
+ import type { QuotaWindow, QuotaProvider } from "@zhushanwen/pi-quota-providers";
9
+
10
+ // ── 时间常量 ───────────────────────────────────────────
11
+
12
+ const MS_PER_SEC = 1000;
13
+ const SEC_PER_MIN = 60;
14
+ const MIN_PER_HOUR = 60;
15
+ const HOURS_PER_DAY = 24;
16
+ const SEC_PER_HOUR = SEC_PER_MIN * MIN_PER_HOUR;
17
+ const SEC_PER_DAY = SEC_PER_HOUR * HOURS_PER_DAY;
18
+
19
+ // ── token 数字单位阈值 ─────────────────────────────────
20
+
21
+ const KILO = 1_000;
22
+ const MILLION = 1_000_000;
23
+
24
+ // ── 百分比阈值 ─────────────────────────────────────────
25
+
26
+ const PCT_HIGH = 80;
27
+ const PCT_MED = 60;
28
+ const PCT_LOW = 40;
29
+ const PERCENT_SCALE = 100;
30
+
31
+ // ── 渲染常量 ───────────────────────────────────────────
32
+
33
+ /** 标题列宽(按最长 "minimax-token-plan"=18,+1 空格余量) */
34
+ export const TITLE_COL_W = 19;
35
+ /** reset 时间列宽(fmtResetSec 最长 "12d23h"=6 + 1 空格余量) */
36
+ export const RESET_COL_W = 7;
37
+ /** pct 列宽("100%"=4,但 padStart(3) 给 " 23%"=4) */
38
+ export const PCT_COL_W = 3;
39
+
40
+ // ── 列定义 ─────────────────────────────────────────────
41
+
42
+ export const COLS = [
43
+ { key: "5h", label: "5h" },
44
+ { key: "week", label: "wk" },
45
+ { key: "month", label: "mh" },
46
+ ] as const;
47
+
48
+ // ── 格式化函数 ─────────────────────────────────────────
49
+
50
+ const MIN_PAD = 2;
51
+
52
+ export function fmtDuration(ms: number): string {
53
+ const s = Math.floor(ms / MS_PER_SEC);
54
+ if (s < SEC_PER_MIN) return `${s}s`;
55
+ const m = Math.floor(s / SEC_PER_MIN);
56
+ if (m < MIN_PER_HOUR) return `${m}m${String(s % SEC_PER_MIN).padStart(MIN_PAD, "0")}s`;
57
+ return `${Math.floor(m / MIN_PER_HOUR)}h${String(m % MIN_PER_HOUR).padStart(MIN_PAD, "0")}m`;
58
+ }
59
+
60
+ export function fmtTokens(n: number): string {
61
+ if (n >= MILLION) return `${(n / MILLION).toFixed(1)}M`;
62
+ if (n >= KILO) return `${(n / KILO).toFixed(1)}K`;
63
+ return `${n}`;
64
+ }
65
+
66
+ export function fmtResetSec(sec: number): string {
67
+ if (sec <= 0) return "";
68
+ const d = Math.floor(sec / SEC_PER_DAY);
69
+ const h = Math.floor((sec % SEC_PER_DAY) / SEC_PER_HOUR);
70
+ const m = Math.floor((sec % SEC_PER_HOUR) / SEC_PER_MIN);
71
+ if (d > 0) return `${d}d${h}h`;
72
+ if (h > 0) return `${h}h${m}m`;
73
+ return `${m}m`;
74
+ }
75
+
76
+ export function fmtCount(n: number): string {
77
+ return n < KILO ? `${n}` : `${(n / KILO).toFixed(1)}k`;
78
+ }
79
+
80
+ /** 按百分比返回语义色 token */
81
+ export function pctColor(pct: number): "error" | "warning" | "accent" | "success" {
82
+ if (pct >= PCT_HIGH) return "error";
83
+ if (pct >= PCT_MED) return "warning";
84
+ if (pct >= PCT_LOW) return "accent";
85
+ return "success";
86
+ }
87
+
88
+ // ── 速度渲染 ─────────────────────────────────────────
89
+
90
+ export interface SpeedLike {
91
+ current: number;
92
+ day: number;
93
+ }
94
+
95
+ /** 渲染速度部分:speed 123t/s · day 85t/s(无速度返回空串) */
96
+ export function formatSpeedPart(sp: SpeedLike, p: PlainPallet): string {
97
+ const parts: string[] = [];
98
+ if (sp.current > 0) parts.push(`${p.g(`${sp.current}`)}${p.d("t/s")}`);
99
+ if (sp.day > 0) parts.push(`${p.d("day")} ${p.g(`${sp.day}`)}${p.d("t/s")}`);
100
+ return parts.length ? `│ ${p.d("speed")} ${parts.join(" · ")}` : "";
101
+ }
102
+
103
+ // ── 路径工具 ─────────────────────────────────────────
104
+
105
+ /** 把路径切成段(按系统分隔符) */
106
+ export function splitPath(p: string): string[] {
107
+ return p.split("/").filter(Boolean);
108
+ }
109
+
110
+ /** 截取 sessionId 文件名的末尾 N 字符(去路径) */
111
+ export function tailSessionId(filePath: string | undefined, n: number): string {
112
+ if (!filePath) return "";
113
+ return filePath.split("/").pop()?.slice(-n) ?? "";
114
+ }
115
+
116
+ // ── Palette(strip ANSI 的 plain 版本,用于测试) ──────
117
+
118
+ export interface PlainPallet {
119
+ d: (s: string) => string;
120
+ v: (s: string) => string;
121
+ g: (s: string) => string;
122
+ w: (s: string) => string;
123
+ a: (s: string) => string;
124
+ m: (s: string) => string;
125
+ }
126
+
127
+ /** 无 ANSI 色码的 palette,返回原始字符串 */
128
+ export const plainPallet: PlainPallet = {
129
+ d: (s) => s,
130
+ v: (s) => s,
131
+ g: (s) => s,
132
+ w: (s) => s,
133
+ a: (s) => s,
134
+ m: (s) => s,
135
+ };
136
+
137
+ /** 模拟 Theme.fg — 只返回原始文本 */
138
+ export const plainThemeFg = (_token: string, text: string) => text;
139
+
140
+ // ── 行数据类型 ─────────────────────────────────────────
141
+
142
+ export interface QuotaRow {
143
+ name: string;
144
+ wins: [QuotaWindow, QuotaWindow, QuotaWindow];
145
+ }
146
+
147
+ // ── 核心渲染函数 ───────────────────────────────────────
148
+
149
+ /** 缓存数据 → 归一化行(用于 token-plans 显示) */
150
+ export function normalizeRows(
151
+ cache: Record<string, unknown>,
152
+ providers: QuotaProvider[],
153
+ ): QuotaRow[] {
154
+ const rows: QuotaRow[] = [];
155
+ for (const p of providers) {
156
+ if (p.category !== "token-plan") continue;
157
+ try {
158
+ const raw = cache[p.id];
159
+ if (!raw) continue;
160
+ const norm = p.normalize(raw);
161
+ if (!norm) continue;
162
+ // 优先使用 providers.json 配置的 label,fallback 到 normalize 返回的 label
163
+ rows.push({ name: p.label || norm.label, wins: norm.wins });
164
+ } catch {
165
+ // eslint-disable-next-line taste/no-silent-catch -- 单 provider normalize 失败不应拖垮整个 statusline
166
+ }
167
+ }
168
+ return rows;
169
+ }
170
+
171
+ /** 渲染搜索工具行 */
172
+ export function buildSearchLine(
173
+ cache: Record<string, unknown>,
174
+ providers: QuotaProvider[],
175
+ p: PlainPallet,
176
+ themeFg: (token: string, text: string) => string,
177
+ ): string {
178
+ const parts: string[] = [];
179
+ for (const prov of providers) {
180
+ if (prov.category !== "search-tool") continue;
181
+ const raw = cache[prov.id] as Record<string, unknown> | undefined;
182
+ if (!raw) continue;
183
+ const used = (raw.planUsage as number) ?? (raw.available as number);
184
+ const total = (raw.planLimit as number) ?? (raw.total as number);
185
+ if (used === undefined || !total || total <= 0) continue;
186
+ const pct = Math.round((used / total) * PERCENT_SCALE);
187
+ const pctCol = themeFg(pctColor(pct), `${pct}%`);
188
+ parts.push(`${p.d(prov.label)} ${p.g(`${used}`)}/${p.v(`${total}`)}${p.d("次")} ${pctCol}`);
189
+ }
190
+ return parts.join(" | ");
191
+ }
192
+
193
+ /** 渲染 token-plan 行列表 */
194
+ export function buildTokenPlanLines(
195
+ cache: Record<string, unknown>,
196
+ providers: QuotaProvider[],
197
+ p: PlainPallet,
198
+ themeFg: (token: string, text: string) => string,
199
+ ): string[] {
200
+ const rows = normalizeRows(cache, providers);
201
+ return rows.map((row) => {
202
+ const title = p.d(row.name.padEnd(TITLE_COL_W));
203
+ const cells = COLS.map((col, i) => {
204
+ const win = row.wins[i]!;
205
+ return formatWinCol(col.label, win, p, themeFg);
206
+ });
207
+ return title + cells.join(" · ");
208
+ });
209
+ }
210
+
211
+ /** 渲染单个窗口列:label pct% [reset](无 bar) */
212
+ export function formatWinCol(
213
+ label: string,
214
+ win: QuotaWindow,
215
+ p: PlainPallet,
216
+ themeFg: (token: string, text: string) => string,
217
+ ): string {
218
+ const pctWidth = PCT_COL_W + 1; // "NNN%" = padStart(3) + 1 = 4 chars
219
+ if (win.pct === null) {
220
+ // 无限:∞ 右对齐到 pctStr 宽度,reset 用 -- 占位
221
+ return `${p.d(label)} ${p.v("∞".padStart(pctWidth))} ${p.v("--".padStart(RESET_COL_W))}`;
222
+ }
223
+ const pctStr = `${String(Math.round(win.pct)).padStart(PCT_COL_W)}%`;
224
+ const rtRaw = win.resetSec != null && win.resetSec > 0 ? fmtResetSec(win.resetSec) : "";
225
+ const rtStr = rtRaw ? p.v(rtRaw.padStart(RESET_COL_W)) : " ".repeat(RESET_COL_W);
226
+ return `${p.d(label)} ${themeFg(pctColor(win.pct), pctStr)} ${rtStr}`;
227
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type QuotaProvider,
29
29
  } from "@zhushanwen/pi-quota-providers";
30
30
  import { registerSetupCommand } from "./setup.js";
31
+ import { formatSpeedPart, splitPath, tailSessionId } from "./format.js";
31
32
 
32
33
  // ── 本地事件类型 ───────────────────────────────────────
33
34
  interface PiMessageEvent {
@@ -38,12 +39,6 @@ interface PiThinkingLevelEvent {
38
39
  level: string;
39
40
  }
40
41
 
41
- interface SearchToolRaw {
42
- available?: number;
43
- used?: number;
44
- total?: number;
45
- }
46
-
47
42
  // ── 时间常量 ───────────────────────────────────────────
48
43
 
49
44
  const MS_PER_SEC = 1000;
@@ -128,17 +123,6 @@ function pctColor(pct: number): "error" | "warning" | "accent" | "success" {
128
123
  return "success";
129
124
  }
130
125
 
131
- /** 把 cwd 切成段,按系统分隔符(macOS/Linux: /;Windows: \) */
132
- function splitPath(p: string): string[] {
133
- return p.split(sep).filter(Boolean);
134
- }
135
-
136
- /** 截取 sessionId 文件名的末尾 N 字符(去路径) */
137
- function tailSessionId(filePath: string | undefined, n: number): string {
138
- if (!filePath) return "";
139
- return filePath.split(sep).pop()?.slice(-n) ?? "";
140
- }
141
-
142
126
  /** 当前 cwd 是否在 git worktree 内(粗略:看 .git 是文件还是目录) */
143
127
  function isWorktree(cwd: string): boolean {
144
128
  return existsSync(join(cwd, ".git"));
@@ -331,7 +315,8 @@ function normalizeRows(cache: CacheData, providers: QuotaProvider[]): QuotaRow[]
331
315
  if (!raw) continue;
332
316
  const norm = p.normalize(raw);
333
317
  if (!norm) continue;
334
- rows.push({ name: norm.label || p.label, wins: norm.wins });
318
+ // 优先使用 providers.json 配置的 label,fallback normalize 返回的 label
319
+ rows.push({ name: p.label || norm.label, wins: norm.wins });
335
320
  // eslint-disable-next-line taste/no-silent-catch -- render 容错:单 provider normalize 失败不应拖垮整个 statusline
336
321
  } catch (e) {
337
322
  console.warn(`[statusline] normalize failed for ${p.id}:`, e);
@@ -361,7 +346,11 @@ function buildLine2(ctx: ExtensionContext, st: StatuslineRuntimeState, p: Pallet
361
346
  const provider = model.provider || "";
362
347
  const modelId = model.id || model.name || "unknown";
363
348
  const tlPart = st.thinkingLevel ? ` ${p.m(`[${st.thinkingLevel}]`)}` : "";
364
- return `${p.d(provider)}/${p.a(modelId)}${tlPart}`;
349
+
350
+ const speedPart = formatSpeedPart(st.speed, p);
351
+ const speedPrefix = speedPart ? ` ${speedPart}` : "";
352
+
353
+ return `${p.d(provider)}/${p.a(modelId)}${tlPart}${speedPrefix}`;
365
354
  }
366
355
 
367
356
  function buildLine3(
@@ -419,10 +408,11 @@ function buildSearchLine(
419
408
  const parts: string[] = [];
420
409
  for (const prov of providers) {
421
410
  if (prov.category !== "search-tool") continue;
422
- const raw = (cache as Record<string, unknown>)[prov.id] as SearchToolRaw | undefined;
411
+ const raw = (cache as Record<string, unknown>)[prov.id] as Record<string, unknown> | undefined;
423
412
  if (!raw) continue;
424
- const used = raw.used ?? raw.available;
425
- const total = raw.total;
413
+ // 优先使用 planUsage/planLimit(API 调用次数),fallback available/total(key 数量)
414
+ const used = (raw.planUsage as number) ?? (raw.available as number);
415
+ const total = (raw.planLimit as number) ?? (raw.total as number);
426
416
  if (used === undefined || !total || total <= 0) continue;
427
417
  const pct = Math.round((used / total) * PERCENT_SCALE);
428
418
  const pctCol = theme.fg(pctColor(pct), `${pct}%`);
@@ -450,8 +440,10 @@ function buildTokenPlanLines(
450
440
 
451
441
  /** 渲染单个窗口列:label pct% [reset](无 bar) */
452
442
  function formatWinCol(label: string, win: QuotaWindow, p: Pallet, theme: Theme): string {
443
+ const pctWidth = PCT_COL_W + 1; // "NNN%" = padStart(3) + 1 = 4 chars
453
444
  if (win.pct === null) {
454
- return `${p.d(label)} ${p.v("∞")}`;
445
+ // 无限:∞ 右对齐到 pctStr 宽度,reset 用 -- 占位
446
+ return `${p.d(label)} ${p.v("∞".padStart(pctWidth))} ${p.v("--".padStart(RESET_COL_W))}`;
455
447
  }
456
448
  const pctStr = `${String(Math.round(win.pct)).padStart(PCT_COL_W)}%`;
457
449
  const rtRaw = win.resetSec != null && win.resetSec > 0 ? fmtResetSec(win.resetSec) : "";
package/src/setup.ts CHANGED
@@ -59,12 +59,7 @@ export function registerSetupCommand(pi: ExtensionAPI): void {
59
59
  missing,
60
60
  });
61
61
 
62
- try {
63
- await ctx.sessionManager.appendEntry("user", prompt);
64
- } catch (e) {
65
- ctx.ui.notify(`Failed to inject setup prompt: ${(e as Error).message}`, "error");
66
- return;
67
- }
62
+ pi.sendUserMessage(prompt);
68
63
  ctx.ui.notify(`Setup wizard started. Will generate: ${missing.join(", ")}`, "info");
69
64
  },
70
65
  });