@zhushanwen/pi-statusline 0.1.3 → 0.4.0

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
@@ -1,38 +1,192 @@
1
1
  # statusline
2
2
 
3
- Pi 自定义状态栏 — 显示上下文用量、Token 速度、Provider 套餐额度。
3
+ Pi 自定义状态栏 — 显示上下文用量、Token 流量、Provider 套餐额度、搜索工具额度。
4
4
 
5
- ## 功能
5
+ ## 状态栏布局
6
6
 
7
- - **Line 1**:目录/仓库名 · 分支 │ session-name │ provider : model [thinking level]
8
- - **Line 2**:上下文用量 Token 速度(当前 + 日累计)│ 搜索配额
9
- - **Line 3-5**:套餐用量(Z.ai-pro / opencode-go / kimi-coding 等),进度条可视化
10
- - **Line 6**:时间 · 费用 · session ID
7
+ ```
8
+ Line 1: 父目录/子目录 · branch worktree
9
+ Line 2: provider/model [thinking level]
10
+ Line 3: ctx 45.2K/200K 23% │ from 13:25 · run 34m40s · last 12s │ ↑↓ 128.3k/8.5k │ 253b75.jsonl
11
+ Line 4: tavily 234/1000次 23% | anysearch 250/500次 50%
12
+ Line 5+: zhipu-coding-plan 5h 23% 4h11m · wk ∞ · mh ∞
13
+ opencode-go 5h 45% 2h35m · wk 12% 3d2h · mh 78% 4d5h
14
+ kimi-coding-plan 5h 32% 1h42m · wk 45% 2d8h · mh ∞
15
+ minimax-token-plan 5h 10% 4h55m · wk 8% 5d1h · mh 15% 12d
16
+ ```
17
+
18
+ | 行 | 内容 | 说明 |
19
+ |---|------|------|
20
+ | 1 | 目录 + 分支 + worktree | 仓库路径显示倒数两级;worktree 文字标识 |
21
+ | 2 | `provider/model [thinking]` | 完整 provider/model;thinking level 灰显 |
22
+ | 3 | 上下文 + 时间 + 流量 + 会话 ID | `ctx` 百分比按区间配色(绿/黄/红);`from` 启动时刻;`run` 运行时长;`last` 距上次 LLM 响应;`↑↓` 累计 input/output token;最后是 session 文件后缀 |
23
+ | 4 | 搜索工具额度 | 多个工具用 ` \| ` 分隔;格式 `{label} {used}/{total}次 {pct}%` |
24
+ | 5+ | token-plans 套餐 | 3 列:5h / wk / mh;去进度条纯文本;`∞` 表示无限;reset 时间右对齐 |
11
25
 
12
26
  ## 安装
13
27
 
14
28
  ```bash
15
- # symlink 方式(开发推荐)
16
- ln -s /path/to/xyz-pi-extensions-workspace/main/packages/statusline \
29
+ # npm 方式(唯一正式方式)
30
+ pi install npm:@zhushanwen/pi-statusline
31
+
32
+ # 本地开发(symlink)
33
+ ln -s /path/to/xyz-pi-extensions-workspace/main/extensions/statusline \
17
34
  ~/.pi/agent/extensions/statusline
35
+ ```
18
36
 
19
- # npm 方式(正式)
20
- pi install npm:@zhushanwen/pi-statusline
37
+ ## 配置
38
+
39
+ 扩展通过**声明式 JSON 配置**管理 provider 和凭证。首次使用需要运行:
40
+
41
+ ```bash
42
+ /setup-statusline
43
+ ```
44
+
45
+ 命令行为:
46
+ - 配置文件都存在 → 加载并打印审查摘要
47
+ - 缺失 → 注入 LLM prompt,让 LLM 生成 demo 文件
48
+ - `providers.json` 默认启用所有内置 provider(用户后续可禁用)
49
+ - `secrets.json` 默认所有凭证用 `${ENV_VAR}` 引用(不写明文)
50
+ - 支持中英文(基于 `Intl.DateTimeFormat().resolvedOptions().locale`)
51
+
52
+ ### 配置文件位置
53
+
54
+ | 文件 | 路径 | 作用 |
55
+ |------|------|------|
56
+ | providers.json | `~/.pi/agent/config/providers.json` | provider 声明 |
57
+ | secrets.json | `~/.pi/agent/config/secrets.json` | 凭证(明文或 env 引用) |
58
+
59
+ 路径通过 `getAgentDir()` 派生,**不写绝对路径**。
60
+
61
+ ### providers.json schema
62
+
63
+ ```json
64
+ {
65
+ "token-plans": [
66
+ {
67
+ "id": "zhipu",
68
+ "label": "zhipu-coding-plan",
69
+ "enabled": true,
70
+ "fetcher": "zhipu"
71
+ }
72
+ ],
73
+ "search-tools": [
74
+ {
75
+ "id": "tavily",
76
+ "label": "tavily",
77
+ "enabled": true,
78
+ "fetcher": "tavily"
79
+ }
80
+ ]
81
+ }
82
+ ```
83
+
84
+ | 字段 | 必填 | 说明 |
85
+ |------|------|------|
86
+ | `id` | ✓ | 在 cache 中的 key |
87
+ | `label` | ✓ | 状态栏显示名 |
88
+ | `enabled` | ✓ | `false` 跳过该 provider(保留配置便于回滚) |
89
+ | `fetcher` | ✓ | 内置 fetcher ID(见下方支持列表) |
90
+
91
+ ### secrets.json schema
92
+
93
+ ```json
94
+ {
95
+ "zhipu": {
96
+ "token": "${ZAI_AUTH_TOKEN}"
97
+ },
98
+ "tavily": {
99
+ "apiKey": "tvly-plain-text-token-here"
100
+ }
101
+ }
102
+ ```
103
+
104
+ - 每个 provider 是一个 section
105
+ - value 字符串匹配 `^\$\{[A-Z_][A-Z0-9_]*\}$` → 当作环境变量引用,从 `process.env` 取
106
+ - 环境变量不存在 → 静默返回空串(该 provider 拉不到数据)
107
+ - 其它值 → 原样使用
108
+
109
+ ## 内置 Provider
110
+
111
+ | fetcher | 类别 | 周期 | 说明 |
112
+ |---------|------|------|------|
113
+ | `zhipu` | token-plan | 5h | 智谱 GLM Coding |
114
+ | `opencode-go` | token-plan | 5h/wk/mh | Go API |
115
+ | `kimi-coding` | token-plan | 5h/wk/mh | Kimi |
116
+ | `minimax` | token-plan | 5h/wk/mh | MiniMax |
117
+ | `tavily` | search-tool | 次数 | 搜索 API |
118
+
119
+ - **token-plan**:按 3 窗口(5h / wk / mh)显示用量 + reset 时间
120
+ - **search-tool**:按 `used/total次` 显示搜索配额,多个工具用 `|` 分隔
121
+
122
+ ## 添加新 Provider
123
+
124
+ 三步走,**statusline 代码零修改**:
125
+
126
+ ### 1. 实现 fetcher
127
+
128
+ 在 `shared/quota-providers/src/providers/xxx.ts`:
129
+
130
+ ```typescript
131
+ import type { QuotaProvider, NormalizedQuotaRow } from "./types.js";
132
+ import { INFINITE_WIN } from "./types.js";
133
+
134
+ export interface XxxData {
135
+ // 你的原始数据结构
136
+ pct: number;
137
+ resetSec: number;
138
+ }
139
+
140
+ async function fetchXxx(): Promise<XxxData | null> {
141
+ // 从 API 拉数据;失败/无凭证返回 null
142
+ return null;
143
+ }
144
+
145
+ export const xxxProvider: QuotaProvider<XxxData> = {
146
+ id: "xxx",
147
+ label: "xxx-plan",
148
+ category: "token-plan", // 或 "search-tool"
149
+ fetch: fetchXxx,
150
+ normalize(raw): NormalizedQuotaRow | null {
151
+ return {
152
+ label: "xxx-plan",
153
+ wins: [
154
+ { pct: raw.pct, resetSec: raw.resetSec },
155
+ INFINITE_WIN,
156
+ INFINITE_WIN,
157
+ ],
158
+ };
159
+ },
160
+ };
21
161
  ```
22
162
 
23
- ## 使用
163
+ ### 2. 在 registry.ts 注册
24
164
 
25
- 安装后自动生效,Pi 底部状态栏自动显示信息。
165
+ `shared/quota-providers/src/registry.ts` 的 `FETCHERS` 和 `NORMALIZERS` 表加一行:
26
166
 
27
- ## 支持的 Provider
167
+ ```typescript
168
+ const FETCHERS: Record<string, Fetcher> = {
169
+ // ...
170
+ "xxx": xxxProvider.fetch as Fetcher,
171
+ };
28
172
 
29
- | Provider | 额度类型 |
30
- |----------|---------|
31
- | Z.ai-pro (智谱) | 5h 重置周期 |
32
- | opencode-go (Go API) | 周期/周/月额度 |
33
- | kimi-coding (Kimi) | 周期额度 |
34
- | minimax | 周期额度 |
35
- | tavily | 搜索次数 |
173
+ const NORMALIZERS: Record<string, Normalize> = {
174
+ // ...
175
+ "xxx": xxxProvider.normalize as Normalize,
176
+ };
177
+ ```
178
+
179
+ ### 3. 用户在 providers.json 启用
180
+
181
+ ```json
182
+ {
183
+ "token-plans": [
184
+ { "id": "xxx", "label": "xxx-plan", "enabled": true, "fetcher": "xxx" }
185
+ ]
186
+ }
187
+ ```
188
+
189
+ 完事。状态栏下次渲染自动出现新行。
36
190
 
37
191
  ## 文件结构
38
192
 
@@ -40,14 +194,37 @@ pi install npm:@zhushanwen/pi-statusline
40
194
  statusline/
41
195
  ├── index.ts
42
196
  └── src/
43
- ├── index.ts # 入口 — Footer 渲染
44
- ├── cache.ts # 数据缓存 + Token 速度追踪
197
+ ├── index.ts # 入口 — Footer 渲染 + 状态机
198
+ ├── setup.ts # /setup-statusline 命令
199
+ └── setup-prompts.ts # i18n prompt 模板
200
+
201
+ shared/quota-providers/ # workspace 共享包
202
+ ├── index.ts
203
+ └── src/
204
+ ├── cache.ts # TTL 缓存 + Token 速度追踪
205
+ ├── config.ts # providers.json 加载器
206
+ ├── secrets.ts # secrets.json 加载器
207
+ ├── paths.ts # 路径工具(getAgentDir)
208
+ ├── registry.ts # 运行时 provider 构建
45
209
  └── providers/
46
- ├── index.ts # Provider 注册
47
- ├── types.ts # 额度类型定义
48
- ├── zhipu.ts # 智谱
49
- ├── opencode-go.ts# Go API
50
- ├── kimi-coding.ts# Kimi
51
- ├── minimax.ts # MiniMax
52
- └── tavily.ts # 搜索
210
+ ├── index.ts # Provider 注册表
211
+ ├── types.ts # QuotaProvider 接口
212
+ ├── zhipu.ts
213
+ ├── opencode-go.ts
214
+ ├── kimi-coding.ts
215
+ ├── minimax.ts
216
+ └── tavily.ts
53
217
  ```
218
+
219
+ ## 性能 / 缓存
220
+
221
+ - provider 数据通过 `cache.ts` 缓存,TTL 5 分钟
222
+ - `triggerUpdate()` 在 `session_start` / `message_end` 触发,但实际请求受 TTL/2 节流
223
+ - `fetch` 失败 / 无凭证 → 保留旧值(Promise.allSettled 模式)
224
+ - Token 速度按模型分别存到 `~/.pi/agent/token-stats/<model>.json`,30 天滚动窗口
225
+
226
+ ## 调试
227
+
228
+ - `npx tsc --noEmit` — 类型检查
229
+ - 修改 `providers.json` 后无需重启,下次 render 自动 reload
230
+ - provider 加载失败会在 console.warn(`unknown fetcher: xxx`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-statusline",
3
- "version": "0.1.3",
3
+ "version": "0.4.0",
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,7 +22,7 @@
22
22
  "index.ts"
23
23
  ],
24
24
  "dependencies": {
25
- "@zhushanwen/pi-quota-providers": "0.1.2"
25
+ "@zhushanwen/pi-quota-providers": "0.4.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@mariozechner/pi-coding-agent": "*",
package/src/index.ts CHANGED
@@ -2,153 +2,151 @@
2
2
  * Pi Statusline — 自定义状态栏
3
3
  *
4
4
  * 布局:
5
- * Line 1: 目录/仓库名 · 分支 session-name provider : model [thinking level]
6
- * Line 2: ctx │ speed current+t/s day+t/s │ tavily
7
- * Line 3-5: 套餐用量(统一列对齐)
8
- * Z.ai-pro 5h XXX% [bar] ZzHh · wk ∞ · mh ∞ reset ZhZm
9
- * opencode-go 5h XXX% [bar] ZhZm · wk XXX% [bar] Zdh · mh XXX% [bar] Zdh
10
- * kimi-coding 5h XXX% [bar] ZhZm · wk XXX% [bar] Zdh · mh ∞
11
- * Line 6: 时间 · 费用 · 会话ID
5
+ * Line 1: 父目录/子目录 · branchworktree
6
+ * Line 2: provider/model [thinking level]
7
+ * Line 3: ctx X/Y 23% │ from · run · last │ ↑↓ in/out │ <sessionId>
8
+ * Line 4: search-tool 行(tavily 234/1000次 23% | anysearch 250/500次 50%)
9
+ * Line 5+: token-plans 行(去 bar,列对齐)
10
+ *
11
+ * 配置:通过 ~/.pi/agent/config/{providers,secrets}.json 声明式管理
12
12
  */
13
13
 
14
+ import { existsSync } from "node:fs";
15
+ import { join, sep } from "node:path";
14
16
  import type { AssistantMessage } from "@mariozechner/pi-ai";
15
17
  import type { ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@mariozechner/pi-coding-agent";
16
18
  import { truncateToWidth } from "@mariozechner/pi-tui";
17
19
 
18
- // ── 本地事件类型 ───────────────────────────────────────
19
- // Pi SDK 将 ContextEvent 定义为 any,此处用具体接口替代
20
- interface PiMessageEvent {
21
- message: { role: string } & Record<string, unknown>;
22
- }
23
-
24
- interface PiThinkingLevelEvent {
25
- level: string;
26
- }
27
20
  import {
28
21
  readCache,
29
22
  triggerUpdate,
30
23
  trackSpeed,
31
- PROVIDERS,
24
+ buildRuntimeProviders,
32
25
  type CacheData,
33
26
  type SpeedData,
34
27
  type QuotaWindow,
28
+ type QuotaProvider,
35
29
  } from "@zhushanwen/pi-quota-providers";
36
- // ── 常量 ───────────────────────────────────────────────
30
+ import { registerSetupCommand } from "./setup.js";
37
31
 
38
- const SEP = "│";
39
- const DOT = "·";
40
- const WIDE_THRESHOLD = 100;
41
- const RUN_UPDATE_MS = 5000;
42
-
43
- /** 标题列宽(按最长 "opencode-go"=11, +4 空格余量) */
44
- const TITLE_COL_W = 15;
45
-
46
- // ── Bar rendering (no ANSI) ──────────────────────────
47
-
48
- /** Render a usage bar with Unicode block chars and semantic fg tokens. */
49
- function barSegment(pct: number, theme: Theme, w = 6): string {
50
- const p = Math.max(0, Math.min(100, Math.round(pct)));
51
- const filled = Math.floor((p * w) / 100);
52
- const fillToken =
53
- p >= 80 ? "error"
54
- : p >= 60 ? "warning"
55
- : p >= 40 ? "accent"
56
- : "success";
57
- return theme.fg(fillToken, "█".repeat(filled)) + theme.fg("muted", "░".repeat(w - filled));
32
+ // ── 本地事件类型 ───────────────────────────────────────
33
+ interface PiMessageEvent {
34
+ message: { role: string } & Record<string, unknown>;
58
35
  }
59
36
 
60
- /** 构建一个窗口列,所有单元格 data 区域固定可见宽度。 */
61
- function winCol(
62
- label: string,
63
- pct: number | null,
64
- resetSec: number | null,
65
- wide: boolean,
66
- d: (s: string) => string,
67
- v: (s: string) => string,
68
- theme: Theme,
69
- ): string {
70
- const l = d(label);
71
- if (pct === null) {
72
- const dataW = wide ? 20 : 12;
73
- return `${l} ${v(padCenter("∞", dataW))}`;
74
- }
75
- const pctStr = `${String(Math.round(pct)).padStart(3)}%`;
76
- const rtRaw = resetSec && resetSec > 0 ? fmtResetSec(resetSec) : "";
77
- const rtStr = rtRaw.padEnd(6);
78
-
79
- if (wide) {
80
- const bar = barSegment(pct, theme, 6);
81
- return `${l} ${v(pctStr)} ${bar} ${v(rtStr)}`;
82
- }
83
- return `${l} ${v(pctStr)} ${v(rtStr)}`;
37
+ interface PiThinkingLevelEvent {
38
+ level: string;
84
39
  }
85
40
 
86
- /** str 居中到 width 宽度,用空格填充 */
87
- function padCenter(str: string, width: number): string {
88
- const pad = width - str.length;
89
- if (pad <= 0) return str.slice(0, width);
90
- const left = Math.floor(pad / 2);
91
- const right = pad - left;
92
- return " ".repeat(left) + str + " ".repeat(right);
41
+ interface SearchToolRaw {
42
+ available?: number;
43
+ used?: number;
44
+ total?: number;
93
45
  }
94
46
 
47
+ // ── 时间常量 ───────────────────────────────────────────
48
+
49
+ const MS_PER_SEC = 1000;
50
+ const SEC_PER_MIN = 60;
51
+ const MIN_PER_HOUR = 60;
52
+ const HOURS_PER_DAY = 24;
53
+ const SEC_PER_HOUR = SEC_PER_MIN * MIN_PER_HOUR;
54
+ const SEC_PER_DAY = SEC_PER_HOUR * HOURS_PER_DAY;
55
+
56
+ // ── 渲染常量 ───────────────────────────────────────────
57
+
58
+ const SEP = "│";
59
+ const DOT = "·";
60
+ const RUN_UPDATE_MS = 5000;
61
+ /** 标题列宽(按最长 "minimax-token-plan"=18,+1 空格余量) */
62
+ const TITLE_COL_W = 19;
63
+ /** reset 时间列宽(fmtResetSec 最长 "12d23h"=6 + 1 空格余量) */
64
+ const RESET_COL_W = 7;
65
+ /** pct 列宽("100%"=4,但 padStart(3) 给 " 23%"=4) */
66
+ const PCT_COL_W = 3;
67
+ /** sessionId 截取末尾字符数 */
68
+ const SESSION_ID_TAIL = 12;
69
+ /** 路径展示的层数(cwd 倒数 N 段) */
70
+ const DIR_DEPTH = 2;
71
+ /** 分/秒 pad 宽度 */
72
+ const MIN_PAD = 2;
73
+ /** bogus replay 阈值:output > 50 tokens 但 duration < 100ms 视为重放,跳过速度统计 */
74
+ const BOGUS_OUTPUT_THRESHOLD = 50;
75
+ const BOGUS_DURATION_THRESHOLD_MS = 100;
76
+
77
+ // ── 阈值常量 ───────────────────────────────────────────
78
+
79
+ /** token 数字单位阈值 */
80
+ const KILO = 1_000;
81
+ const MILLION = 1_000_000;
82
+
83
+ /** pct 颜色分档 */
84
+ const PCT_HIGH = 80;
85
+ const PCT_MED = 60;
86
+ const PCT_LOW = 40;
87
+ /** 百分比标度 */
88
+ const PERCENT_SCALE = 100;
89
+
90
+ /** contextWindow fallback */
91
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
92
+
93
+ // ── 工具函数 ───────────────────────────────────────────
94
+
95
95
  function fmtDuration(ms: number): string {
96
- const s = Math.floor(ms / 1000);
97
- if (s < 60) return `${s}s`;
98
- const m = Math.floor(s / 60);
99
- if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
100
- return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
96
+ const s = Math.floor(ms / MS_PER_SEC);
97
+ if (s < SEC_PER_MIN) return `${s}s`;
98
+ const m = Math.floor(s / SEC_PER_MIN);
99
+ if (m < MIN_PER_HOUR) return `${m}m${String(s % SEC_PER_MIN).padStart(MIN_PAD, "0")}s`;
100
+ return `${Math.floor(m / MIN_PER_HOUR)}h${String(m % MIN_PER_HOUR).padStart(MIN_PAD, "0")}m`;
101
101
  }
102
102
 
103
103
  function fmtTokens(n: number): string {
104
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
105
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
104
+ if (n >= MILLION) return `${(n / MILLION).toFixed(1)}M`;
105
+ if (n >= KILO) return `${(n / KILO).toFixed(1)}K`;
106
106
  return `${n}`;
107
107
  }
108
108
 
109
109
  function fmtResetSec(sec: number): string {
110
110
  if (sec <= 0) return "";
111
- const d = Math.floor(sec / 86400);
112
- const h = Math.floor((sec % 86400) / 3600);
113
- const m = Math.floor((sec % 3600) / 60);
111
+ const d = Math.floor(sec / SEC_PER_DAY);
112
+ const h = Math.floor((sec % SEC_PER_DAY) / SEC_PER_HOUR);
113
+ const m = Math.floor((sec % SEC_PER_HOUR) / SEC_PER_MIN);
114
114
  if (d > 0) return `${d}d${h}h`;
115
115
  if (h > 0) return `${h}h${m}m`;
116
116
  return `${m}m`;
117
117
  }
118
118
 
119
- // ── 归一化的套餐窗口列 ─────────────────────────────────
119
+ function fmtCount(n: number): string {
120
+ return n < KILO ? `${n}` : `${(n / KILO).toFixed(1)}k`;
121
+ }
120
122
 
121
- interface QuotaRow {
122
- name: string;
123
- wins: [QuotaWindow, QuotaWindow, QuotaWindow];
123
+ /** 按百分比返回语义色 token */
124
+ function pctColor(pct: number): "error" | "warning" | "accent" | "success" {
125
+ if (pct >= PCT_HIGH) return "error";
126
+ if (pct >= PCT_MED) return "warning";
127
+ if (pct >= PCT_LOW) return "accent";
128
+ return "success";
124
129
  }
125
130
 
126
- const COLS = [
127
- { key: "5h", label: "5h" },
128
- { key: "week", label: "wk" },
129
- { key: "month", label: "mh" },
130
- ] as const;
131
+ /** cwd 切成段,按系统分隔符(macOS/Linux: /;Windows: \) */
132
+ function splitPath(p: string): string[] {
133
+ return p.split(sep).filter(Boolean);
134
+ }
131
135
 
132
- /** 将缓存数据归一化为对齐的行数组(走注册表)。 */
133
- function normalizeRows(cache: CacheData): QuotaRow[] {
134
- const rows: QuotaRow[] = [];
135
- for (const p of PROVIDERS) {
136
- try {
137
- const raw = (cache as Record<string, unknown>)[p.id];
138
- if (!raw) continue;
139
- const norm = p.normalize(raw);
140
- if (!norm) continue;
141
- rows.push({ name: norm.label || p.label, wins: norm.wins });
142
- } catch {
143
- // 单个 provider normalize 失败不影响其他 provider 显示
144
- }
145
- }
146
- return rows;
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
+ /** 当前 cwd 是否在 git worktree 内(粗略:看 .git 是文件还是目录) */
143
+ function isWorktree(cwd: string): boolean {
144
+ return existsSync(join(cwd, ".git"));
147
145
  }
148
146
 
149
147
  // ── 状态 ───────────────────────────────────────────────
150
148
 
151
- interface State {
149
+ interface StatuslineRuntimeState {
152
150
  sessionStart: number;
153
151
  lastLlmTime: number;
154
152
  assistantStart: number;
@@ -162,14 +160,10 @@ interface State {
162
160
  usedPct: number;
163
161
  contextTokens: number;
164
162
  contextWindow: number;
165
- treeTokens: number;
166
- treeId: string;
167
163
  }
168
164
 
169
- // ── 扩展入口 ───────────────────────────────────────────
170
-
171
- export default function (pi: ExtensionAPI) {
172
- const state: State = {
165
+ function makeInitialState(): StatuslineRuntimeState {
166
+ return {
173
167
  sessionStart: 0,
174
168
  lastLlmTime: 0,
175
169
  assistantStart: 0,
@@ -183,22 +177,28 @@ export default function (pi: ExtensionAPI) {
183
177
  usedPct: 0,
184
178
  contextTokens: 0,
185
179
  contextWindow: 0,
186
- treeTokens: 0,
187
- treeId: "",
188
180
  };
181
+ }
189
182
 
183
+ // ── 扩展入口 ───────────────────────────────────────────
184
+
185
+ export default function statuslineExtension(pi: ExtensionAPI) {
186
+ registerSetupCommand(pi);
187
+ registerSessionLifecycle(pi);
188
+ }
189
+
190
+ function registerSessionLifecycle(pi: ExtensionAPI): void {
191
+ const state: StatuslineRuntimeState = makeInitialState();
190
192
  let tui: { requestRender(): void } | null = null;
191
193
 
192
194
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
193
- state.sessionStart = Date.now();
194
- state.lastLlmTime = 0;
195
- state.speed = { current: 0, day: 0, d7: 0, d30: 0 };
196
- state.isAgentBusy = false;
197
- state.thinkingLevel = pi.getThinkingLevel();
195
+ Object.assign(state, makeInitialState(), {
196
+ sessionStart: Date.now(),
197
+ thinkingLevel: pi.getThinkingLevel(),
198
+ });
198
199
  refreshTotals(state, ctx);
199
200
 
200
- // eslint-disable-next-line @typescript-eslint/no-explicit-any SDK ExtensionContext.ui 类型缺失 setFooter
201
- (ctx.ui as any).setFooter((t: { requestRender(): void }, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
201
+ ctx.ui.setFooter((t: { requestRender(): void }, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
202
202
  tui = t;
203
203
  const unsub = footerData.onBranchChange(() => t.requestRender());
204
204
  return {
@@ -213,49 +213,47 @@ export default function (pi: ExtensionAPI) {
213
213
  triggerUpdate();
214
214
  });
215
215
 
216
- pi.on("message_start", async (event: PiMessageEvent) => {
216
+ pi.on("message_start", (event: PiMessageEvent) => {
217
217
  if (event.message.role === "assistant") {
218
218
  state.assistantStart = Date.now();
219
219
  state.isAgentBusy = true;
220
220
  }
221
221
  });
222
222
 
223
- pi.on("message_end", async (event: PiMessageEvent, ctx: ExtensionContext) => {
224
- if (event.message.role === "assistant") {
225
- const msg = event.message as AssistantMessage;
226
- if (!msg.usage) return;
227
- const dur = state.assistantStart ? Date.now() - state.assistantStart : 0;
228
- state.lastLlmTime = Date.now();
229
- const isBogusReplay = msg.usage.output > 50 && dur < 100;
230
- if (isBogusReplay) {
231
- state.speed = { current: 0, day: 0, d7: 0, d30: 0 };
232
- return;
233
- }
234
- state.speed = trackSpeed(msg.usage.output, dur, ctx.model?.id ?? "");
235
- state.totalInp += msg.usage.input;
236
- state.totalOut += msg.usage.output;
237
- state.totalCost += msg.usage.cost.total;
238
- refreshContextUsage(state, ctx);
239
- tui?.requestRender();
240
- triggerUpdate();
223
+ pi.on("message_end", (event: PiMessageEvent, ctx: ExtensionContext) => {
224
+ if (event.message.role !== "assistant") return;
225
+ const msg = event.message as AssistantMessage;
226
+ if (!msg.usage) return;
227
+ const dur = state.assistantStart ? Date.now() - state.assistantStart : 0;
228
+ state.lastLlmTime = Date.now();
229
+ if (msg.usage.output > BOGUS_OUTPUT_THRESHOLD && dur < BOGUS_DURATION_THRESHOLD_MS) {
230
+ state.speed = { current: 0, day: 0, d7: 0, d30: 0 };
231
+ return;
241
232
  }
233
+ state.speed = trackSpeed(msg.usage.output, dur, ctx.model?.id ?? "");
234
+ state.totalInp += msg.usage.input;
235
+ state.totalOut += msg.usage.output;
236
+ state.totalCost += msg.usage.cost.total;
237
+ refreshContextUsage(state, ctx);
238
+ tui?.requestRender();
239
+ triggerUpdate();
242
240
  });
243
241
 
244
- pi.on("turn_end", async () => {
242
+ pi.on("turn_end", () => {
245
243
  state.isAgentBusy = false;
246
244
  state.lastRunUpdate = Date.now();
247
245
  tui?.requestRender();
248
246
  });
249
- pi.on("agent_end", async () => {
247
+ pi.on("agent_end", () => {
250
248
  state.isAgentBusy = false;
251
249
  state.lastRunUpdate = Date.now();
252
250
  tui?.requestRender();
253
251
  });
254
- pi.on("model_select", async () => {
252
+ pi.on("model_select", () => {
255
253
  state.thinkingLevel = pi.getThinkingLevel();
256
254
  tui?.requestRender();
257
255
  });
258
- pi.on("thinking_level_select", async (event: PiThinkingLevelEvent) => {
256
+ pi.on("thinking_level_select", (event: PiThinkingLevelEvent) => {
259
257
  state.thinkingLevel = event.level;
260
258
  if (!state.isAgentBusy) tui?.requestRender();
261
259
  });
@@ -263,7 +261,7 @@ export default function (pi: ExtensionAPI) {
263
261
 
264
262
  // ── 数据刷新 ───────────────────────────────────────────
265
263
 
266
- function refreshTotals(st: State, ctx: ExtensionContext): void {
264
+ function refreshTotals(st: StatuslineRuntimeState, ctx: ExtensionContext): void {
267
265
  let inp = 0, out = 0, cost = 0;
268
266
  for (const e of ctx.sessionManager.getBranch()) {
269
267
  if (e.type === "message" && e.message.role === "assistant") {
@@ -280,187 +278,208 @@ function refreshTotals(st: State, ctx: ExtensionContext): void {
280
278
  refreshContextUsage(st, ctx);
281
279
  }
282
280
 
283
- function refreshContextUsage(st: State, ctx: ExtensionContext): void {
281
+ function refreshContextUsage(st: StatuslineRuntimeState, ctx: ExtensionContext): void {
284
282
  const usage = ctx.getContextUsage();
285
283
  if (!usage || usage.tokens === null) return;
286
- const contextWindow = usage.contextWindow || 128_000;
284
+ const contextWindow = usage.contextWindow || DEFAULT_CONTEXT_WINDOW;
287
285
  st.contextTokens = usage.tokens;
288
286
  st.contextWindow = contextWindow;
289
- st.usedPct = Math.min(Math.round((usage.tokens / contextWindow) * 100), 100);
290
- refreshTreeTokens(st, ctx);
287
+ st.usedPct = Math.min(Math.round((usage.tokens / contextWindow) * PERCENT_SCALE), PERCENT_SCALE);
291
288
  }
292
289
 
293
- /** session entries 中读取最新 ic-compact-tree 的 totalTokens */
294
- function refreshTreeTokens(st: State, ctx: ExtensionContext): void {
295
- let latestTokens: number | undefined;
296
- let latestTreeId: string | undefined;
297
- for (const e of ctx.sessionManager.getEntries()) {
298
- if (e.type === "custom" && (e as { customType: string }).customType === "ic-compact-tree") {
299
- const data = (e as { data?: { totalTokens?: number; treeId?: string } }).data;
300
- if (data?.totalTokens != null) latestTokens = data.totalTokens;
301
- if (data?.treeId != null) latestTreeId = data.treeId;
302
- }
303
- }
304
- st.treeTokens = latestTokens ?? 0;
305
- st.treeId = latestTreeId ?? "";
290
+ // ── 渲染 ───────────────────────────────────────────────
291
+
292
+ interface QuotaRow {
293
+ name: string;
294
+ wins: [QuotaWindow, QuotaWindow, QuotaWindow];
306
295
  }
307
296
 
308
- // ── 渲染 ───────────────────────────────────────────────
297
+ const COLS = [
298
+ { key: "5h", label: "5h" },
299
+ { key: "week", label: "wk" },
300
+ { key: "month", label: "mh" },
301
+ ] as const;
309
302
 
310
- function buildLines(
311
- ctx: ExtensionContext,
312
- theme: Theme,
313
- fd: ReadonlyFooterDataProvider,
314
- width: number,
315
- st: State,
316
- ): string[] {
317
- const cache = readCache();
318
- const wide = width >= WIDE_THRESHOLD;
303
+ type Pallet = {
304
+ d: (s: string) => string;
305
+ v: (s: string) => string;
306
+ g: (s: string) => string;
307
+ w: (s: string) => string;
308
+ a: (s: string) => string;
309
+ m: (s: string) => string;
310
+ };
319
311
 
312
+ function makePalette(theme: Theme): Pallet {
320
313
  const fg = (c: string, t: string) => theme.fg(c, t);
321
- const d = (s: string) => fg("dim", s);
322
- const v = (s: string) => fg("text", s);
323
- const g = (s: string) => fg("success", s);
324
- const w = (s: string) => fg("warning", s);
325
- const a = (s: string) => fg("accent", s);
326
- const m = (s: string) => fg("muted", s);
327
-
328
- const lines: string[] = [];
329
-
330
- // ═══════════════════════════════════════════════════
331
- // Line 1: 目录/仓库 · 分支 │ session-name │ provider : model [thinking]
332
- // ═══════════════════════════════════════════════════
333
- const branch = fd.getGitBranch();
334
- const cwd = ctx.cwd || "";
314
+ return {
315
+ d: (s) => fg("dim", s),
316
+ v: (s) => fg("text", s),
317
+ g: (s) => fg("success", s),
318
+ w: (s) => fg("warning", s),
319
+ a: (s) => fg("accent", s),
320
+ m: (s) => fg("muted", s),
321
+ };
322
+ }
335
323
 
336
- const idParts: string[] = [];
337
- if (branch) {
338
- const segs = cwd.split("/").filter(Boolean);
339
- const repoName = segs.slice(-2).join("/");
340
- if (repoName) idParts.push(a(repoName));
341
- idParts.push(`⎇ ${g(branch)}`);
342
- } else {
343
- const segs = cwd.split("/").filter(Boolean);
344
- const last2 = segs.slice(-2).join("/");
345
- if (last2) idParts.push(a(last2));
324
+ /** 缓存数据 归一化行(用于 token-plans 显示) */
325
+ function normalizeRows(cache: CacheData, providers: QuotaProvider[]): QuotaRow[] {
326
+ const rows: QuotaRow[] = [];
327
+ for (const p of providers) {
328
+ if (p.category !== "token-plan") continue;
329
+ try {
330
+ const raw = (cache as Record<string, unknown>)[p.id];
331
+ if (!raw) continue;
332
+ const norm = p.normalize(raw);
333
+ if (!norm) continue;
334
+ rows.push({ name: norm.label || p.label, wins: norm.wins });
335
+ // eslint-disable-next-line taste/no-silent-catch -- render 容错:单 provider normalize 失败不应拖垮整个 statusline
336
+ } catch (e) {
337
+ console.warn(`[statusline] normalize failed for ${p.id}:`, e);
338
+ }
346
339
  }
340
+ return rows;
341
+ }
347
342
 
348
- let line1 = idParts.join(` ${DOT} `);
343
+ // ── 5 个独立行渲染函数 ─────────────────────────────────
349
344
 
350
- // Session name (set by /name command)
351
- const sessionName = ctx.sessionManager.getSessionName();
352
- if (sessionName) {
353
- line1 += ` ${SEP} ${a(sessionName)}`;
354
- }
345
+ function buildLine1(ctx: ExtensionContext, fd: ReadonlyFooterDataProvider, p: Pallet): string {
346
+ const branch = fd.getGitBranch();
347
+ const cwd = ctx.cwd || "";
348
+ const segs = splitPath(cwd);
349
+ const dirLabel = segs.slice(-DIR_DEPTH).join(sep) || cwd;
350
+ const inWt = isWorktree(cwd);
351
+
352
+ const parts: string[] = [p.a(dirLabel)];
353
+ if (branch) parts.push(`⎇ ${p.g(branch)}`);
354
+ if (inWt) parts.push(p.d("worktree"));
355
+ return parts.join(` ${DOT} `);
356
+ }
355
357
 
358
+ function buildLine2(ctx: ExtensionContext, st: StatuslineRuntimeState, p: Pallet): string {
356
359
  const model = ctx.model;
357
- if (model) {
358
- const provider = model.provider || "";
359
- const provShort = provider.includes("/")
360
- ? provider.split("/").pop()!
361
- : provider;
362
- const modelId = model.id || model.name || "unknown";
363
- const tlPart = st.thinkingLevel ? ` ${m(`[${st.thinkingLevel}]`)}` : "";
364
- line1 += ` ${SEP} ${d(provShort)} : ${a(modelId)}${tlPart}`;
365
- }
366
- if (line1) lines.push(line1);
367
-
368
- // ═══════════════════════════════════════════════════
369
- // Line 2: ctx │ speed current+t/s day+t/s │ tavily
370
- // ═══════════════════════════════════════════════════
371
- const ctxSizeStr =
372
- st.contextWindow > 0
373
- ? `${d("ctx")} ${v(fmtTokens(st.contextTokens))}/${v(fmtTokens(st.contextWindow))}`
374
- : `${d("ctx")} ${v(`${st.usedPct}%`)}`;
375
- const ctxBarStr = wide
376
- ? `${barSegment(st.usedPct, theme)} ${v(`${st.usedPct}%`)}`
377
- : `${v(`${st.usedPct}%`)}`;
378
-
379
- const line2Parts: string[] = [`${ctxSizeStr} ${ctxBarStr}`];
380
-
381
- // tree-ctx:格式和 ctx 相同,始终展示
382
- if (st.contextWindow > 0) {
383
- const treePctRaw = (st.treeTokens / st.contextWindow) * 100;
384
- const treePct = Math.min(Math.round(treePctRaw), 100);
385
- const treeDisplayPct = treePct === 0 && st.treeTokens > 0 ? "<1" : `${treePct}`;
386
- const treeSizeStr = `${d("tree")} ${v(fmtTokens(st.treeTokens))}/${v(fmtTokens(st.contextWindow))}`;
387
- const treeBarStr = wide
388
- ? `${barSegment(treePct || 1, theme)} ${v(`${treeDisplayPct}%`)}`
389
- : `${v(`${treeDisplayPct}%`)}`;
390
- line2Parts.push(`${treeSizeStr} ${treeBarStr}`);
391
- }
360
+ if (!model) return "";
361
+ const provider = model.provider || "";
362
+ const modelId = model.id || model.name || "unknown";
363
+ const tlPart = st.thinkingLevel ? ` ${p.m(`[${st.thinkingLevel}]`)}` : "";
364
+ return `${p.d(provider)}/${p.a(modelId)}${tlPart}`;
365
+ }
392
366
 
393
- const sp: string[] = [];
394
- if (st.speed.current > 0)
395
- sp.push(`${g(`${st.speed.current}`)}${d("t/s")}`);
396
- if (st.speed.day > 0)
397
- sp.push(`${d("day")} ${g(`${st.speed.day}`)}${d("t/s")}`);
398
- if (sp.length)
399
- line2Parts.push(`${d("speed")} ${sp.join(` ${DOT} `)}`);
400
-
401
- const tv = cache["tavily"] as { available: number; total: number } | undefined;
402
- if (tv)
403
- line2Parts.push(`${d("tavily")} ${g(`${tv.available}`)}/${v(`${tv.total}`)}`);
404
-
405
- lines.push(line2Parts.join(` ${SEP} `));
406
-
407
- // ═══════════════════════════════════════════════════
408
- // Line 3+: 套餐用量(归一化 → 统一列渲染)
409
- // ═══════════════════════════════════════════════════
410
- const rows = normalizeRows(cache);
411
- for (const row of rows) {
412
- const title = d(row.name.padEnd(TITLE_COL_W));
413
- const cells = COLS.map((col, i) => {
414
- const win = row.wins[i]!;
415
- return winCol(col.label, win.pct, win.resetSec, wide, d, v, theme);
416
- });
417
- lines.push(title + cells.join(` ${DOT} `));
418
- }
367
+ function buildLine3(
368
+ ctx: ExtensionContext,
369
+ st: StatuslineRuntimeState,
370
+ p: Pallet,
371
+ theme: Theme,
372
+ ): string {
373
+ const ctxPct = st.usedPct;
374
+ const ctxPctCol = theme.fg(pctColor(ctxPct), `${ctxPct}%`);
375
+ const ctxStr = st.contextWindow > 0
376
+ ? `${p.d("ctx")} ${p.v(fmtTokens(st.contextTokens))}/${p.v(fmtTokens(st.contextWindow))} ${ctxPctCol}`
377
+ : `${p.d("ctx")} ${ctxPctCol}`;
419
378
 
420
- // ═══════════════════════════════════════════════════
421
- // 末行: 时间 · 费用 · 会话ID
422
- // ═══════════════════════════════════════════════════
423
379
  const tp: string[] = [];
424
380
  if (st.sessionStart) {
425
381
  const from = new Date(st.sessionStart);
426
- tp.push(
427
- `${d("from")} ${g(`${from.getHours()}:${String(from.getMinutes()).padStart(2, "0")}`)}`,
428
- );
382
+ tp.push(`${p.d("from")} ${p.g(`${from.getHours()}:${String(from.getMinutes()).padStart(MIN_PAD, "0")}`)}`);
429
383
  }
430
- const shouldRefreshRun =
431
- !st.isAgentBusy &&
432
- (st.lastRunUpdate === 0 || Date.now() - st.lastRunUpdate >= RUN_UPDATE_MS);
433
- if (shouldRefreshRun) st.lastRunUpdate = Date.now();
434
- const displayRunMs = st.lastRunUpdate
435
- ? st.lastRunUpdate - st.sessionStart
436
- : st.sessionStart ? Date.now() - st.sessionStart : 0;
437
- if (displayRunMs > 0) tp.push(`${d("run")} ${g(fmtDuration(displayRunMs))}`);
384
+ if (shouldRefreshRun(st)) st.lastRunUpdate = Date.now();
385
+ const displayRunMs = computeRunMs(st);
386
+ if (displayRunMs > 0) tp.push(`${p.d("run")} ${p.g(fmtDuration(displayRunMs))}`);
438
387
  if (st.lastLlmTime) {
439
- const ago = Math.floor((Date.now() - st.lastLlmTime) / 1000);
440
- tp.push(
441
- `${d("last")} ${w(ago < 60 ? `${ago}s` : `${Math.floor(ago / 60)}m${ago % 60}s`)}`,
442
- );
388
+ const ago = Math.floor((Date.now() - st.lastLlmTime) / MS_PER_SEC);
389
+ tp.push(`${p.d("last")} ${p.w(ago < SEC_PER_MIN ? `${ago}s` : `${Math.floor(ago / SEC_PER_MIN)}m${ago % SEC_PER_MIN}s`)}`);
443
390
  }
444
391
 
445
- const sid =
446
- ctx.sessionManager
447
- .getSessionFile()
448
- ?.split("/")
449
- .pop()
450
- ?.slice(-12) || "";
392
+ const sid = tailSessionId(ctx.sessionManager.getSessionFile(), SESSION_ID_TAIL);
451
393
 
452
- const info: string[] = [];
453
- if (tp.length) info.push(tp.join(` ${DOT} `));
454
- if (st.totalCost > 0) info.push(`${d("cost")} ${w(`$${st.totalCost.toFixed(3)}`)}`);
394
+ const parts: string[] = [ctxStr];
395
+ if (tp.length) parts.push(tp.join(` ${DOT} `));
455
396
  if (st.totalInp > 0 || st.totalOut > 0) {
456
- const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
457
- info.push(`${d("↑↓")} ${v(fmt(st.totalInp))}/${v(fmt(st.totalOut))}`);
397
+ parts.push(`${p.d("↑↓")} ${p.v(fmtCount(st.totalInp))}/${p.v(fmtCount(st.totalOut))}`);
458
398
  }
459
- const treeSid = st.treeId ? st.treeId.replace(/^tree_/, "").slice(-8) : "";
460
- if (treeSid) info.push(`${d("tree")} ${m(treeSid)}`);
461
- if (sid) info.push(m(sid));
399
+ if (sid) parts.push(p.m(sid));
400
+ return parts.join(` ${SEP} `);
401
+ }
402
+
403
+ function shouldRefreshRun(st: StatuslineRuntimeState): boolean {
404
+ return !st.isAgentBusy && (st.lastRunUpdate === 0 || Date.now() - st.lastRunUpdate >= RUN_UPDATE_MS);
405
+ }
406
+
407
+ function computeRunMs(st: StatuslineRuntimeState): number {
408
+ if (st.lastRunUpdate) return st.lastRunUpdate - st.sessionStart;
409
+ if (st.sessionStart) return Date.now() - st.sessionStart;
410
+ return 0;
411
+ }
412
+
413
+ function buildSearchLine(
414
+ cache: CacheData,
415
+ providers: QuotaProvider[],
416
+ p: Pallet,
417
+ theme: Theme,
418
+ ): string {
419
+ const parts: string[] = [];
420
+ for (const prov of providers) {
421
+ if (prov.category !== "search-tool") continue;
422
+ const raw = (cache as Record<string, unknown>)[prov.id] as SearchToolRaw | undefined;
423
+ if (!raw) continue;
424
+ const used = raw.used ?? raw.available;
425
+ const total = raw.total;
426
+ if (used === undefined || !total || total <= 0) continue;
427
+ const pct = Math.round((used / total) * PERCENT_SCALE);
428
+ const pctCol = theme.fg(pctColor(pct), `${pct}%`);
429
+ parts.push(`${p.d(prov.label)} ${p.g(`${used}`)}/${p.v(`${total}`)}${p.d("次")} ${pctCol}`);
430
+ }
431
+ return parts.join(" | ");
432
+ }
462
433
 
463
- if (info.length) lines.push(info.join(` ${SEP} `));
434
+ function buildTokenPlanLines(
435
+ cache: CacheData,
436
+ providers: QuotaProvider[],
437
+ p: Pallet,
438
+ theme: Theme,
439
+ ): string[] {
440
+ const rows = normalizeRows(cache, providers);
441
+ return rows.map((row) => {
442
+ const title = p.d(row.name.padEnd(TITLE_COL_W));
443
+ const cells = COLS.map((col, i) => {
444
+ const win = row.wins[i]!;
445
+ return formatWinCol(col.label, win, p, theme);
446
+ });
447
+ return title + cells.join(` ${DOT} `);
448
+ });
449
+ }
464
450
 
465
- return lines.map((l) => truncateToWidth(l, width));
451
+ /** 渲染单个窗口列:label pct% [reset](无 bar) */
452
+ function formatWinCol(label: string, win: QuotaWindow, p: Pallet, theme: Theme): string {
453
+ if (win.pct === null) {
454
+ return `${p.d(label)} ${p.v("∞")}`;
455
+ }
456
+ const pctStr = `${String(Math.round(win.pct)).padStart(PCT_COL_W)}%`;
457
+ const rtRaw = win.resetSec != null && win.resetSec > 0 ? fmtResetSec(win.resetSec) : "";
458
+ const rtStr = rtRaw ? p.v(rtRaw.padStart(RESET_COL_W)) : " ".repeat(RESET_COL_W);
459
+ return `${p.d(label)} ${theme.fg(pctColor(win.pct), pctStr)} ${rtStr}`;
460
+ }
461
+
462
+ function buildLines(
463
+ ctx: ExtensionContext,
464
+ theme: Theme,
465
+ fd: ReadonlyFooterDataProvider,
466
+ width: number,
467
+ st: StatuslineRuntimeState,
468
+ ): string[] {
469
+ const cache = readCache();
470
+ const providers = buildRuntimeProviders();
471
+ const palette = makePalette(theme);
472
+
473
+ const lines: string[] = [
474
+ buildLine1(ctx, fd, palette),
475
+ buildLine2(ctx, st, palette),
476
+ buildLine3(ctx, st, palette, theme),
477
+ buildSearchLine(cache, providers, palette, theme),
478
+ ...buildTokenPlanLines(cache, providers, palette, theme),
479
+ ];
480
+
481
+ // 过滤空行(line2/line3 在某些状态下可能空,line4 没搜索工具时空)
482
+ return lines
483
+ .filter((l) => l.length > 0)
484
+ .map((l) => truncateToWidth(l, width));
466
485
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * setup-statusline 命令的 i18n prompt 模板
3
+ *
4
+ * 中英文切换:基于 Intl.DateTimeFormat().resolvedOptions().locale
5
+ * 失败/非 zh locale → 英文
6
+ *
7
+ * demo 模板:providers.json 的结构由 quota-providers.PROVIDERS 动态生成
8
+ * (新增 provider 自动出现,无需改本文件)
9
+ * secrets.json 的 env var 映射在下面 DEFAULT_SECRETS 集中维护
10
+ * (用户编辑后会自动覆盖默认值)
11
+ */
12
+
13
+ import { PROVIDERS } from "@zhushanwen/pi-quota-providers";
14
+
15
+ export type Locale = "zh" | "en";
16
+
17
+ export function detectLocale(): Locale {
18
+ try {
19
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
20
+ return /^zh/i.test(locale) ? "zh" : "en";
21
+ } catch {
22
+ return "en";
23
+ }
24
+ }
25
+
26
+ export interface SetupPromptArgs {
27
+ configDir: string;
28
+ providersPath: string;
29
+ secretsPath: string;
30
+ missing: string[];
31
+ }
32
+
33
+ /** provider.id → 默认 secret 字段(key=value 形式,value 是 env var 占位符) */
34
+ const DEFAULT_SECRETS: Record<string, Record<string, string>> = {
35
+ zhipu: { token: "${ZAI_AUTH_TOKEN}" },
36
+ "opencode-go": { token: "${OPENCODE_GO_TOKEN}" },
37
+ "kimi-coding": { token: "${KIMI_AUTH_TOKEN}" },
38
+ minimax: { token: "${MINIMAX_TOKEN}" },
39
+ tavily: { apiKey: "${TAVILY_API_KEY}" },
40
+ };
41
+
42
+ /** 从 PROVIDERS 动态生成 providers.json demo(自动跟随新增 provider) */
43
+ function buildDemoProvidersJson(): string {
44
+ // 按 category 分组
45
+ const groups: Record<string, string[]> = { "token-plans": [], "search-tools": [] };
46
+ for (const p of PROVIDERS) {
47
+ const key = p.category === "search-tool" ? "search-tools" : "token-plans";
48
+ groups[key]!.push(JSON.stringify({
49
+ id: p.id,
50
+ label: p.label,
51
+ enabled: true,
52
+ fetcher: p.id,
53
+ }));
54
+ }
55
+
56
+ const lines: string[] = ["{"];
57
+ const sections: string[] = [];
58
+ for (const [key, items] of Object.entries(groups)) {
59
+ if (items.length === 0) continue;
60
+ sections.push(` "${key}": [\n${items.map((s) => ` ${s}`).join(",\n")}\n ]`);
61
+ }
62
+ lines.push(sections.join(",\n"));
63
+ lines.push("}");
64
+ return lines.join("\n");
65
+ }
66
+
67
+ /** 从 DEFAULT_SECRETS 动态生成 secrets.json demo(仅含 PROVIDERS 实际支持的) */
68
+ function buildDemoSecretsJson(): string {
69
+ const lines: string[] = ["{"];
70
+ const sections: string[] = [];
71
+ for (const p of PROVIDERS) {
72
+ const fields = DEFAULT_SECRETS[p.id];
73
+ if (!fields) continue;
74
+ const fieldStr = Object.entries(fields)
75
+ .map(([k, v]) => ` "${k}": "${v}"`)
76
+ .join(",\n");
77
+ sections.push(` "${p.id}": {\n${fieldStr}\n }`);
78
+ }
79
+ lines.push(sections.join(",\n"));
80
+ lines.push("}");
81
+ return lines.join("\n");
82
+ }
83
+
84
+ const T = {
85
+ zh: {
86
+ title: "# statusline 配置初始化",
87
+ missing: (m: string[]) => `缺失文件:${m.join(", ")}`,
88
+ task: "请帮我创建 demo 配置文件。",
89
+ constraint1: "secrets.json 凭证请用 \\${ENV_VAR} 环境变量引用形式",
90
+ constraint2: "providers.json 默认启用所有 provider(用户可后续编辑禁用)",
91
+ constraint3: "创建完成后告诉用户可以编辑这两个文件",
92
+ pathsHeader: "## 写入路径",
93
+ providersHeader: "## providers.json demo",
94
+ secretsHeader: "secrets.json demo",
95
+ completeHeader: "## 完成后",
96
+ completeTask: "读两个文件,输出最终内容给用户确认。",
97
+ },
98
+ en: {
99
+ title: "# statusline setup",
100
+ missing: (m: string[]) => `Missing files: ${m.join(", ")}`,
101
+ task: "Generate demo config files for me.",
102
+ constraint1: "Use \\${ENV_VAR} format in secrets.json for credentials",
103
+ constraint2: "Enable all providers in providers.json by default (user can disable later)",
104
+ constraint3: "After creating, tell the user they can edit these files",
105
+ pathsHeader: "## Target paths",
106
+ providersHeader: "## providers.json demo",
107
+ secretsHeader: "## secrets.json demo",
108
+ completeHeader: "## After writing",
109
+ completeTask: "Read both files and show the final content to the user for confirmation.",
110
+ },
111
+ } as const;
112
+
113
+ /** 生成让 LLM 写 demo 文件的引导 prompt */
114
+ export function buildGenerateDemoPrompt(args: SetupPromptArgs): string {
115
+ const t = T[detectLocale()];
116
+ return [
117
+ t.title,
118
+ "",
119
+ t.missing(args.missing),
120
+ "",
121
+ "## 任务",
122
+ t.task,
123
+ "",
124
+ "## 重要约束",
125
+ `1. ${t.constraint1}`,
126
+ `2. ${t.constraint2}`,
127
+ `3. ${t.constraint3}`,
128
+ "",
129
+ t.pathsHeader,
130
+ `- providers.json: \`${args.providersPath}\``,
131
+ `- secrets.json: \`${args.secretsPath}\``,
132
+ "",
133
+ t.providersHeader,
134
+ "```json",
135
+ buildDemoProvidersJson(),
136
+ "```",
137
+ "",
138
+ t.secretsHeader,
139
+ "```json",
140
+ buildDemoSecretsJson(),
141
+ "```",
142
+ "",
143
+ t.completeHeader,
144
+ t.completeTask,
145
+ ].join("\n");
146
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * setup-statusline 命令
3
+ *
4
+ * 行为:
5
+ * 1. 检查 ~/.pi/agent/config/{providers,secrets}.json 是否存在
6
+ * 2. 都存在 → 加载并显示审查摘要
7
+ * 3. 缺失 → 注入 prompt 让 LLM 生成 demo 文件
8
+ *
9
+ * 故意不做:交互式 wizard(让 LLM 处理)、chmod 校验、自动迁移老路径
10
+ */
11
+
12
+ import { existsSync, mkdirSync } from "node:fs";
13
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
14
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
15
+ import {
16
+ getConfigDir,
17
+ getProvidersConfigPath,
18
+ getSecretsPath,
19
+ loadProvidersConfig,
20
+ loadSecrets,
21
+ } from "@zhushanwen/pi-quota-providers";
22
+ import { buildGenerateDemoPrompt } from "./setup-prompts.js";
23
+
24
+ export function registerSetupCommand(pi: ExtensionAPI): void {
25
+ pi.registerCommand("setup-statusline", {
26
+ description: "生成 statusline 的 providers.json + secrets.json demo(LLM 引导)",
27
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
28
+ const configDir = getConfigDir();
29
+ const providersPath = getProvidersConfigPath();
30
+ const secretsPath = getSecretsPath();
31
+
32
+ try {
33
+ mkdirSync(configDir, { recursive: true });
34
+ } catch (e) {
35
+ ctx.ui.notify(`Failed to create ${configDir}: ${(e as Error).message}`, "error");
36
+ return;
37
+ }
38
+
39
+ const hasProviders = existsSync(providersPath);
40
+ const hasSecrets = existsSync(secretsPath);
41
+
42
+ // 都存在 → 审查模式
43
+ if (hasProviders && hasSecrets) {
44
+ const cfg = loadProvidersConfig();
45
+ const secrets = loadSecrets();
46
+ ctx.ui.notify(formatReviewSummary(cfg, secrets), "info");
47
+ return;
48
+ }
49
+
50
+ // 缺失 → 让 LLM 写 demo
51
+ const missing: string[] = [];
52
+ if (!hasProviders) missing.push("providers.json");
53
+ if (!hasSecrets) missing.push("secrets.json");
54
+
55
+ const prompt = buildGenerateDemoPrompt({
56
+ configDir,
57
+ providersPath,
58
+ secretsPath,
59
+ missing,
60
+ });
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
+ }
68
+ ctx.ui.notify(`Setup wizard started. Will generate: ${missing.join(", ")}`, "info");
69
+ },
70
+ });
71
+ }
72
+
73
+ function formatReviewSummary(
74
+ cfg: ReturnType<typeof loadProvidersConfig>,
75
+ secrets: ReturnType<typeof loadSecrets>,
76
+ ): string {
77
+ const lines: string[] = ["=== statusline config ==="];
78
+
79
+ lines.push("token-plans:");
80
+ if (cfg["token-plans"].length === 0) {
81
+ lines.push(" (none)");
82
+ } else {
83
+ for (const p of cfg["token-plans"]) {
84
+ const mark = p.enabled ? "✓" : "✗";
85
+ const tag = secrets[p.id] ? "[secret ok]" : "[no secret]";
86
+ lines.push(` ${mark} ${p.label} (${p.id}) ${tag}`);
87
+ }
88
+ }
89
+
90
+ lines.push("search-tools:");
91
+ if (cfg["search-tools"].length === 0) {
92
+ lines.push(" (none)");
93
+ } else {
94
+ for (const p of cfg["search-tools"]) {
95
+ const mark = p.enabled ? "✓" : "✗";
96
+ const tag = secrets[p.id] ? "[secret ok]" : "[no secret]";
97
+ lines.push(` ${mark} ${p.label} (${p.id}) ${tag}`);
98
+ }
99
+ }
100
+
101
+ return lines.join("\n");
102
+ }