@zhangweiii/pi-status-line 0.1.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/LICENSE +21 -0
- package/README.md +76 -0
- package/extensions/status-line.ts +781 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zhangwei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# pi-status-line
|
|
2
|
+
|
|
3
|
+
A pi package that provides a natural-language configurable multi-line status line.
|
|
4
|
+
|
|
5
|
+
## Included resource
|
|
6
|
+
|
|
7
|
+
- `extensions/status-line.ts`
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
### Local path
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install /absolute/path/to/pi-status-line
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### npm
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:@zhangweiii/pi-status-line
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### GitHub
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install git:github.com/zhangweiii/pi-status-line
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Multi-line footer rendering
|
|
32
|
+
- Rich status widgets for model, git, tokens, context, session, and environment
|
|
33
|
+
- `/statusline` command for natural-language configuration
|
|
34
|
+
- `configure_statusline` tool for LLM-driven layout updates
|
|
35
|
+
|
|
36
|
+
## Development
|
|
37
|
+
|
|
38
|
+
Use the package locally first:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi install /absolute/path/to/pi-status-line
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then inside pi:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/reload
|
|
48
|
+
/statusline 第一排模型、分支、上下文,第二排费用、today、month、时长
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Publish
|
|
52
|
+
|
|
53
|
+
1. Check whether `package.json` → `name` is available on npm.
|
|
54
|
+
2. Login:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm login
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
3. Publish:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm publish --access public
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
After publishing, users can install it with:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pi install npm:@zhangweiii/pi-status-line
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Notes
|
|
73
|
+
|
|
74
|
+
- This package ships the TypeScript source directly. pi loads extensions via jiti, so a separate build step is not required.
|
|
75
|
+
- pi core packages are declared as `peerDependencies`, following pi package guidance.
|
|
76
|
+
- The extension respects `PI_CODING_AGENT_DIR` for its status line config and session scans, which makes isolated testing and non-default pi runtimes behave correctly.
|
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi Status Line Extension
|
|
3
|
+
*
|
|
4
|
+
* 完全替换 footer,提供与 ccstatusline 对标的所有 widget。
|
|
5
|
+
* 通过自然语言命令配置:/statusline <描述>
|
|
6
|
+
*
|
|
7
|
+
* 支持的 widget:
|
|
8
|
+
* Core: model, thinking
|
|
9
|
+
* Git: git-branch, git-changes, git-files, git-insertions, git-deletions, git-root, git-worktree
|
|
10
|
+
* Tokens: tokens-in, tokens-out, tokens-cached, tokens-total, tokens-daily, tokens-monthly, cache-hit
|
|
11
|
+
* Token Speed: speed-in, speed-out, speed-total
|
|
12
|
+
* Context: context-length, context-pct, context-left, context-bar
|
|
13
|
+
* Session: cost, session-clock, session-turns, session-name
|
|
14
|
+
* Environment: cwd, memory, terminal-width
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
20
|
+
import { execSync } from "node:child_process";
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
// ─── 所有可用 widget 定义 ──────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const ALL_WIDGETS = {
|
|
28
|
+
// Core
|
|
29
|
+
"model": { label: "Model", category: "Core", desc: "当前模型名称" },
|
|
30
|
+
"thinking": { label: "Thinking", category: "Core", desc: "thinking 等级 (off/minimal/low/medium/high/xhigh)" },
|
|
31
|
+
|
|
32
|
+
// Git
|
|
33
|
+
"git-branch": { label: "Git Branch", category: "Git", desc: "git 分支名,带 ⎇ 前缀" },
|
|
34
|
+
"git-changes": { label: "Git Changes", category: "Git", desc: "未提交变更行数 (+insertions,-deletions)" },
|
|
35
|
+
"git-files": { label: "Git Files", category: "Git", desc: "有变更的文件数 (Files: N)" },
|
|
36
|
+
"git-insertions": { label: "Git Insertions", category: "Git", desc: "未提交新增行数 (+N)" },
|
|
37
|
+
"git-deletions": { label: "Git Deletions", category: "Git", desc: "未提交删除行数 (-N)" },
|
|
38
|
+
"git-root": { label: "Git Root", category: "Git", desc: "git 仓库根目录名" },
|
|
39
|
+
"git-worktree": { label: "Git Worktree", category: "Git", desc: "当前 git worktree 名,带 𖠰 前缀" },
|
|
40
|
+
|
|
41
|
+
// Tokens
|
|
42
|
+
"tokens-in": { label: "Tokens Input", category: "Tokens", desc: "本 session 输入 token 数 (In: N)" },
|
|
43
|
+
"tokens-out": { label: "Tokens Output", category: "Tokens", desc: "本 session 输出 token 数 (Out: N)" },
|
|
44
|
+
"tokens-cached": { label: "Tokens Cached", category: "Tokens", desc: "缓存命中 token 数 (Cached: N)" },
|
|
45
|
+
"tokens-total": { label: "Tokens Total", category: "Tokens", desc: "总 token 数 (Total: N)" },
|
|
46
|
+
"tokens-daily": { label: "Daily Tokens", category: "Tokens", desc: "今日所有 session 累计 token 数 (Today: N)" },
|
|
47
|
+
"tokens-monthly": { label: "Monthly Tokens", category: "Tokens", desc: "本月所有 session 累计 token 数 (Month: N)" },
|
|
48
|
+
"cache-hit": { label: "Cache Hit", category: "Tokens", desc: "缓存命中占比 (Cache: N%)" },
|
|
49
|
+
|
|
50
|
+
// Token Speed
|
|
51
|
+
"speed-in": { label: "Input Speed", category: "Token Speed", desc: "输入 token 速率 (tk/s)" },
|
|
52
|
+
"speed-out": { label: "Output Speed", category: "Token Speed", desc: "输出 token 速率 (tk/s)" },
|
|
53
|
+
"speed-total": { label: "Total Speed", category: "Token Speed", desc: "总 token 速率 (tk/s)" },
|
|
54
|
+
|
|
55
|
+
// Context
|
|
56
|
+
"context-length": { label: "Context Length", category: "Context", desc: "当前上下文 token 数 (Ctx: N)" },
|
|
57
|
+
"context-pct": { label: "Context %", category: "Context", desc: "上下文使用百分比 (Ctx: N%)" },
|
|
58
|
+
"context-left": { label: "Context Left", category: "Context", desc: "剩余上下文容量 (Left: N)" },
|
|
59
|
+
"context-bar": { label: "Context Bar", category: "Context", desc: "上下文进度条" },
|
|
60
|
+
|
|
61
|
+
// Session
|
|
62
|
+
"cost": { label: "Session Cost", category: "Session", desc: "本 session 累计费用 (Cost: $N)" },
|
|
63
|
+
"session-clock": { label: "Session Clock", category: "Session", desc: "session 运行时长 (Session: Xhr Ym)" },
|
|
64
|
+
"session-turns": { label: "Session Turns", category: "Session", desc: "当前 branch 的 assistant 响应次数 (Turns: N)" },
|
|
65
|
+
"session-name": { label: "Session Name", category: "Session", desc: "当前 session 名称" },
|
|
66
|
+
|
|
67
|
+
// Environment
|
|
68
|
+
"cwd": { label: "Working Dir", category: "Environment", desc: "当前工作目录 (cwd: ...)" },
|
|
69
|
+
"memory": { label: "Memory Usage", category: "Environment", desc: "系统内存使用 (Mem: used/total)" },
|
|
70
|
+
"terminal-width": { label: "Terminal Width", category: "Environment", desc: "终端宽度列数 (Term: N)" },
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
export type WidgetId = keyof typeof ALL_WIDGETS;
|
|
74
|
+
|
|
75
|
+
// ─── 配置 ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
interface Config {
|
|
78
|
+
rows: WidgetId[][];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_ROWS: WidgetId[][] = [
|
|
82
|
+
["model", "thinking", "git-branch", "git-files", "context-pct", "context-left"],
|
|
83
|
+
["cost", "tokens-in", "tokens-out", "tokens-daily", "tokens-monthly", "session-clock"],
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const LAYOUT_PRESETS = {
|
|
87
|
+
"single-line-balanced": {
|
|
88
|
+
desc: "单排平衡布局:适合宽屏,所有关键信息放在一行",
|
|
89
|
+
rows: [[
|
|
90
|
+
"model", "thinking", "git-branch", "git-files", "context-pct", "context-left",
|
|
91
|
+
"cost", "tokens-in", "tokens-out", "tokens-daily", "tokens-monthly", "session-clock",
|
|
92
|
+
]] as WidgetId[][],
|
|
93
|
+
},
|
|
94
|
+
"two-line-balanced": {
|
|
95
|
+
desc: "双排平衡布局:第一排看当前状态,第二排看 token / cost / 时长",
|
|
96
|
+
rows: DEFAULT_ROWS,
|
|
97
|
+
},
|
|
98
|
+
"two-line-compact": {
|
|
99
|
+
desc: "双排紧凑布局:更适合普通宽度终端,保留核心状态与 token 日/月统计",
|
|
100
|
+
rows: [
|
|
101
|
+
["model", "git-branch", "context-pct", "cost"],
|
|
102
|
+
["tokens-in", "tokens-out", "tokens-daily", "tokens-monthly", "session-clock"],
|
|
103
|
+
] as WidgetId[][],
|
|
104
|
+
},
|
|
105
|
+
"three-line-detailed": {
|
|
106
|
+
desc: "三排详细布局:模型与 git / context 与 cost / token 与时间分层显示",
|
|
107
|
+
rows: [
|
|
108
|
+
["model", "thinking", "git-branch", "git-files"],
|
|
109
|
+
["context-pct", "context-left", "cost"],
|
|
110
|
+
["tokens-in", "tokens-out", "tokens-daily", "tokens-monthly", "session-clock"],
|
|
111
|
+
] as WidgetId[][],
|
|
112
|
+
},
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
type PresetId = keyof typeof LAYOUT_PRESETS;
|
|
116
|
+
|
|
117
|
+
const AGENT_DIR = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
118
|
+
const CONFIG_PATH = join(AGENT_DIR, "statusline.json");
|
|
119
|
+
|
|
120
|
+
function normalizeRows(rows: unknown): WidgetId[][] {
|
|
121
|
+
if (!Array.isArray(rows)) return [];
|
|
122
|
+
return rows
|
|
123
|
+
.map((row) => Array.isArray(row) ? row.filter((w): w is WidgetId => typeof w === "string" && w in ALL_WIDGETS) : [])
|
|
124
|
+
.filter((row) => row.length > 0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function rowsToWidgets(rows: WidgetId[][]): WidgetId[] {
|
|
128
|
+
return rows.flat();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatRows(rows: WidgetId[][]): string {
|
|
132
|
+
return rows.map((row, i) => `row${i + 1}: ${row.join(", ")}`).join(" | ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadConfig(): Config {
|
|
136
|
+
try {
|
|
137
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
138
|
+
|
|
139
|
+
const rows = normalizeRows(raw.rows);
|
|
140
|
+
if (rows.length > 0) return { rows };
|
|
141
|
+
|
|
142
|
+
if (Array.isArray(raw.widgets) && raw.widgets.every((w: string) => w in ALL_WIDGETS)) {
|
|
143
|
+
return { rows: [raw.widgets as WidgetId[]] };
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
return { rows: DEFAULT_ROWS.map((row) => [...row]) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function saveConfig(cfg: Config): void {
|
|
150
|
+
try {
|
|
151
|
+
mkdirSync(AGENT_DIR, { recursive: true });
|
|
152
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ rows: cfg.rows, widgets: rowsToWidgets(cfg.rows) }, null, 2), "utf8");
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── 自然语言解析交给 LLM;本地只保留 reset / help 等确定性命令 ────────────────
|
|
157
|
+
|
|
158
|
+
// ─── Git 工具 ─────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function gitRun(args: string, cwd: string): string {
|
|
161
|
+
try {
|
|
162
|
+
return execSync(`git ${args}`, {
|
|
163
|
+
cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 2000,
|
|
164
|
+
}).toString().trim();
|
|
165
|
+
} catch { return ""; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isGitRepo(cwd: string): boolean {
|
|
169
|
+
return gitRun("rev-parse --is-inside-work-tree", cwd) === "true";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function getGitChangeCounts(cwd: string): { insertions: number; deletions: number } {
|
|
173
|
+
const out = gitRun("diff --shortstat", cwd);
|
|
174
|
+
const ins = /(\d+) insertion/.exec(out)?.[1];
|
|
175
|
+
const del = /(\d+) deletion/.exec(out)?.[1];
|
|
176
|
+
return { insertions: ins ? parseInt(ins) : 0, deletions: del ? parseInt(del) : 0 };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getGitWorktreeName(gitDir: string): string {
|
|
180
|
+
const norm = gitDir.replace(/\\/g, "/");
|
|
181
|
+
let wt = "main";
|
|
182
|
+
if (!norm.endsWith("/.git") && norm !== ".git") {
|
|
183
|
+
const idx = norm.lastIndexOf(".git/worktrees/");
|
|
184
|
+
if (idx !== -1) wt = norm.slice(idx + ".git/worktrees/".length) || "main";
|
|
185
|
+
else {
|
|
186
|
+
const bidx = norm.lastIndexOf("/worktrees/");
|
|
187
|
+
if (bidx !== -1) wt = norm.slice(bidx + "/worktrees/".length) || "main";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return wt;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface GitStats {
|
|
194
|
+
branch: string | null;
|
|
195
|
+
insertions: number;
|
|
196
|
+
deletions: number;
|
|
197
|
+
changedFiles: number;
|
|
198
|
+
rootName: string | null;
|
|
199
|
+
worktreeName: string | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getGitStats(cwd: string, branch: string | null): GitStats | null {
|
|
203
|
+
if (!isGitRepo(cwd)) return null;
|
|
204
|
+
|
|
205
|
+
const { insertions, deletions } = getGitChangeCounts(cwd);
|
|
206
|
+
const status = gitRun("status --porcelain --untracked-files=all", cwd);
|
|
207
|
+
const changedFiles = status ? status.split("\n").filter(Boolean).length : 0;
|
|
208
|
+
|
|
209
|
+
const root = gitRun("rev-parse --show-toplevel", cwd);
|
|
210
|
+
const rootName = root
|
|
211
|
+
? root.replace(/[/\\]+$/, "").split(/[/\\]/).filter(Boolean).pop() ?? root
|
|
212
|
+
: null;
|
|
213
|
+
|
|
214
|
+
const gitDir = gitRun("rev-parse --git-dir", cwd);
|
|
215
|
+
const worktreeName = gitDir ? getGitWorktreeName(gitDir) : null;
|
|
216
|
+
|
|
217
|
+
return { branch, insertions, deletions, changedFiles, rootName, worktreeName };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── 格式化工具 ───────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function fmtTokens(n: number): string {
|
|
223
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
224
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
225
|
+
return String(n);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── 日/月 token 统计(扫描 session 文件)─────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const SESSIONS_DIR = join(AGENT_DIR, "sessions");
|
|
231
|
+
|
|
232
|
+
// 简单内存缓存,避免每次 render 都扫文件
|
|
233
|
+
interface TokenScanCache {
|
|
234
|
+
daily: number;
|
|
235
|
+
monthly: number;
|
|
236
|
+
dailyKey: string; // 当前日期 YYYY-MM-DD
|
|
237
|
+
monthlyKey: string; // 当前月份 YYYY-MM
|
|
238
|
+
lastScan: number; // ms timestamp
|
|
239
|
+
}
|
|
240
|
+
let tokenScanCache: TokenScanCache | null = null;
|
|
241
|
+
const SCAN_TTL = 60_000; // 60 秒刷新一次
|
|
242
|
+
|
|
243
|
+
function getDatePrefix(date: Date): string {
|
|
244
|
+
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
245
|
+
}
|
|
246
|
+
function getMonthPrefix(date: Date): string {
|
|
247
|
+
return date.toISOString().slice(0, 7); // YYYY-MM
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function scanSessionTokens(prefix: string): number {
|
|
251
|
+
let total = 0;
|
|
252
|
+
try {
|
|
253
|
+
const dirs = readdirSync(SESSIONS_DIR);
|
|
254
|
+
for (const dir of dirs) {
|
|
255
|
+
const dirPath = join(SESSIONS_DIR, dir);
|
|
256
|
+
try {
|
|
257
|
+
if (!statSync(dirPath).isDirectory()) continue;
|
|
258
|
+
const files = readdirSync(dirPath).filter(f => f.startsWith(prefix) && f.endsWith(".jsonl"));
|
|
259
|
+
for (const file of files) {
|
|
260
|
+
try {
|
|
261
|
+
const content = readFileSync(join(dirPath, file), "utf8");
|
|
262
|
+
for (const line of content.split("\n")) {
|
|
263
|
+
if (!line.includes('"role":"assistant"')) continue;
|
|
264
|
+
try {
|
|
265
|
+
const entry = JSON.parse(line);
|
|
266
|
+
const msg = entry.message ?? entry;
|
|
267
|
+
if (msg.role === "assistant" && msg.usage?.totalTokens) {
|
|
268
|
+
total += msg.usage.totalTokens;
|
|
269
|
+
}
|
|
270
|
+
} catch {}
|
|
271
|
+
}
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
} catch {}
|
|
277
|
+
return total;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getTokenScanCache(): TokenScanCache {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const today = getDatePrefix(new Date());
|
|
283
|
+
const month = getMonthPrefix(new Date());
|
|
284
|
+
|
|
285
|
+
if (
|
|
286
|
+
tokenScanCache &&
|
|
287
|
+
now - tokenScanCache.lastScan < SCAN_TTL &&
|
|
288
|
+
tokenScanCache.dailyKey === today &&
|
|
289
|
+
tokenScanCache.monthlyKey === month
|
|
290
|
+
) {
|
|
291
|
+
return tokenScanCache;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const daily = scanSessionTokens(today);
|
|
295
|
+
const monthly = scanSessionTokens(month);
|
|
296
|
+
tokenScanCache = { daily, monthly, dailyKey: today, monthlyKey: month, lastScan: now };
|
|
297
|
+
return tokenScanCache;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function fmtCost(usd: number): string {
|
|
301
|
+
if (usd === 0) return "$0.00";
|
|
302
|
+
if (usd < 0.001) return `$${usd.toFixed(5)}`;
|
|
303
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
304
|
+
return `$${usd.toFixed(2)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function fmtDuration(ms: number): string {
|
|
308
|
+
const totalMinutes = Math.floor(ms / 60000);
|
|
309
|
+
if (totalMinutes < 1) return "<1m";
|
|
310
|
+
const h = Math.floor(totalMinutes / 60);
|
|
311
|
+
const m = totalMinutes % 60;
|
|
312
|
+
if (h === 0) return `${m}m`;
|
|
313
|
+
if (m === 0) return `${h}hr`;
|
|
314
|
+
return `${h}hr ${m}m`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function fmtBytes(bytes: number): string {
|
|
318
|
+
const G = 1024 ** 3, M = 1024 ** 2, K = 1024;
|
|
319
|
+
if (bytes >= G) return `${(bytes / G).toFixed(1)}G`;
|
|
320
|
+
if (bytes >= M) return `${(bytes / M).toFixed(0)}M`;
|
|
321
|
+
if (bytes >= K) return `${(bytes / K).toFixed(0)}K`;
|
|
322
|
+
return `${bytes}B`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function makeProgressBar(pct: number, width: number): string {
|
|
326
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
327
|
+
const filled = Math.round((clamped / 100) * width);
|
|
328
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface SessionTokenStats {
|
|
332
|
+
input: number;
|
|
333
|
+
output: number;
|
|
334
|
+
cached: number;
|
|
335
|
+
total: number;
|
|
336
|
+
cost: number;
|
|
337
|
+
assistantTurns: number;
|
|
338
|
+
cacheHitRatio: number | null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function getSessionTokenStats(ctx: ExtensionContext): SessionTokenStats {
|
|
342
|
+
let input = 0, output = 0, cached = 0, total = 0, cost = 0, assistantTurns = 0;
|
|
343
|
+
|
|
344
|
+
for (const e of ctx.sessionManager.getBranch()) {
|
|
345
|
+
if (e.type === "message" && e.message.role === "assistant") {
|
|
346
|
+
const m = e.message as AssistantMessage;
|
|
347
|
+
assistantTurns += 1;
|
|
348
|
+
input += m.usage.input;
|
|
349
|
+
output += m.usage.output;
|
|
350
|
+
cached += m.usage.cacheRead;
|
|
351
|
+
total += m.usage.totalTokens;
|
|
352
|
+
cost += m.usage.cost.total;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const cacheBase = input + cached;
|
|
357
|
+
const cacheHitRatio = cacheBase > 0 ? (cached / cacheBase) * 100 : null;
|
|
358
|
+
|
|
359
|
+
return { input, output, cached, total, cost, assistantTurns, cacheHitRatio };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interface ContextStats {
|
|
363
|
+
tokens: number | null;
|
|
364
|
+
percent: number | null;
|
|
365
|
+
contextWindow: number | null;
|
|
366
|
+
remaining: number | null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getContextStats(ctx: ExtensionContext): ContextStats {
|
|
370
|
+
const usage = ctx.getContextUsage();
|
|
371
|
+
if (!usage) {
|
|
372
|
+
return { tokens: null, percent: null, contextWindow: null, remaining: null };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const tokens = usage.tokens ?? null;
|
|
376
|
+
const contextWindow = usage.contextWindow ?? null;
|
|
377
|
+
const percent = usage.percent ?? (tokens != null && contextWindow ? (tokens / contextWindow) * 100 : null);
|
|
378
|
+
const remaining = tokens != null && contextWindow ? Math.max(0, contextWindow - tokens) : null;
|
|
379
|
+
|
|
380
|
+
return { tokens, percent, contextWindow, remaining };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Widget 渲染 ──────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
interface RenderArgs {
|
|
386
|
+
ctx: ExtensionContext;
|
|
387
|
+
theme: ExtensionContext["ui"]["theme"];
|
|
388
|
+
sessionStart: number;
|
|
389
|
+
// 速率计算用的累积数据
|
|
390
|
+
speedData: { totalMs: number; inputTokens: number; outputTokens: number };
|
|
391
|
+
tokenStats: SessionTokenStats;
|
|
392
|
+
contextStats: ContextStats;
|
|
393
|
+
gitStats: GitStats | null;
|
|
394
|
+
thinkingLevel: string | null;
|
|
395
|
+
sessionName: string | null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderWidget(id: WidgetId, ra: RenderArgs): string | null {
|
|
399
|
+
const { ctx, theme: t, sessionStart, speedData, tokenStats, contextStats, gitStats, thinkingLevel, sessionName } = ra;
|
|
400
|
+
const cwd = ctx.cwd;
|
|
401
|
+
|
|
402
|
+
switch (id) {
|
|
403
|
+
// ── Core ──
|
|
404
|
+
case "model": {
|
|
405
|
+
const m = ctx.model?.id ?? "—";
|
|
406
|
+
return t.fg("muted", "Model: ") + t.fg("accent", m);
|
|
407
|
+
}
|
|
408
|
+
case "thinking": {
|
|
409
|
+
if (!thinkingLevel || thinkingLevel === "off") return null;
|
|
410
|
+
return t.fg("muted", "Think: ") + t.fg("dim", thinkingLevel);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Git ──
|
|
414
|
+
case "git-branch": {
|
|
415
|
+
if (!gitStats?.branch) return t.fg("dim", "⎇ no git");
|
|
416
|
+
return t.fg("muted", "⎇ ") + t.fg("dim", gitStats.branch);
|
|
417
|
+
}
|
|
418
|
+
case "git-changes": {
|
|
419
|
+
if (!gitStats) return t.fg("dim", "(no git)");
|
|
420
|
+
return t.fg("dim", `(+${gitStats.insertions},-${gitStats.deletions})`);
|
|
421
|
+
}
|
|
422
|
+
case "git-files": {
|
|
423
|
+
if (!gitStats) return null;
|
|
424
|
+
return t.fg("muted", "Files: ") + t.fg("dim", String(gitStats.changedFiles));
|
|
425
|
+
}
|
|
426
|
+
case "git-insertions": {
|
|
427
|
+
if (!gitStats) return null;
|
|
428
|
+
return t.fg("success", `+${gitStats.insertions}`);
|
|
429
|
+
}
|
|
430
|
+
case "git-deletions": {
|
|
431
|
+
if (!gitStats) return null;
|
|
432
|
+
return t.fg("error", `-${gitStats.deletions}`);
|
|
433
|
+
}
|
|
434
|
+
case "git-root": {
|
|
435
|
+
if (!gitStats?.rootName) return t.fg("dim", "(no git)");
|
|
436
|
+
return t.fg("dim", `(${gitStats.rootName})`);
|
|
437
|
+
}
|
|
438
|
+
case "git-worktree": {
|
|
439
|
+
if (!gitStats?.worktreeName) return t.fg("dim", "𖠰 no git");
|
|
440
|
+
return t.fg("muted", "𖠰 ") + t.fg("dim", gitStats.worktreeName);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── Tokens ──
|
|
444
|
+
case "tokens-in": {
|
|
445
|
+
return t.fg("muted", "In: ") + t.fg("dim", fmtTokens(tokenStats.input));
|
|
446
|
+
}
|
|
447
|
+
case "tokens-out": {
|
|
448
|
+
return t.fg("muted", "Out: ") + t.fg("dim", fmtTokens(tokenStats.output));
|
|
449
|
+
}
|
|
450
|
+
case "tokens-cached": {
|
|
451
|
+
return t.fg("muted", "Cached: ") + t.fg("dim", fmtTokens(tokenStats.cached));
|
|
452
|
+
}
|
|
453
|
+
case "tokens-total": {
|
|
454
|
+
return t.fg("muted", "Total: ") + t.fg("dim", fmtTokens(tokenStats.total));
|
|
455
|
+
}
|
|
456
|
+
case "tokens-daily": {
|
|
457
|
+
const { daily } = getTokenScanCache();
|
|
458
|
+
return t.fg("muted", "Today: ") + t.fg("dim", fmtTokens(daily));
|
|
459
|
+
}
|
|
460
|
+
case "tokens-monthly": {
|
|
461
|
+
const { monthly } = getTokenScanCache();
|
|
462
|
+
return t.fg("muted", "Month: ") + t.fg("dim", fmtTokens(monthly));
|
|
463
|
+
}
|
|
464
|
+
case "cache-hit": {
|
|
465
|
+
const ratio = tokenStats.cacheHitRatio ?? 0;
|
|
466
|
+
return t.fg("muted", "Cache: ") + t.fg("dim", `${ratio.toFixed(1)}%`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Token Speed ──
|
|
470
|
+
case "speed-in": {
|
|
471
|
+
if (!speedData.totalMs) return null;
|
|
472
|
+
const s = speedData.totalMs / 1000;
|
|
473
|
+
return t.fg("muted", "In: ") + t.fg("dim", `${(speedData.inputTokens / s).toFixed(0)}tk/s`);
|
|
474
|
+
}
|
|
475
|
+
case "speed-out": {
|
|
476
|
+
if (!speedData.totalMs) return null;
|
|
477
|
+
const s = speedData.totalMs / 1000;
|
|
478
|
+
return t.fg("muted", "Out: ") + t.fg("dim", `${(speedData.outputTokens / s).toFixed(0)}tk/s`);
|
|
479
|
+
}
|
|
480
|
+
case "speed-total": {
|
|
481
|
+
if (!speedData.totalMs) return null;
|
|
482
|
+
const s = speedData.totalMs / 1000;
|
|
483
|
+
const total = speedData.inputTokens + speedData.outputTokens;
|
|
484
|
+
return t.fg("muted", "Speed: ") + t.fg("dim", `${(total / s).toFixed(0)}tk/s`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Context ──
|
|
488
|
+
case "context-length": {
|
|
489
|
+
if (contextStats.tokens == null) return null;
|
|
490
|
+
return t.fg("muted", "Ctx: ") + t.fg("dim", fmtTokens(contextStats.tokens));
|
|
491
|
+
}
|
|
492
|
+
case "context-pct": {
|
|
493
|
+
if (contextStats.percent == null) return null;
|
|
494
|
+
return t.fg("muted", "Ctx: ") + t.fg("dim", `${contextStats.percent.toFixed(1)}%`);
|
|
495
|
+
}
|
|
496
|
+
case "context-left": {
|
|
497
|
+
if (contextStats.remaining == null) return null;
|
|
498
|
+
return t.fg("muted", "Left: ") + t.fg("dim", fmtTokens(contextStats.remaining));
|
|
499
|
+
}
|
|
500
|
+
case "context-bar": {
|
|
501
|
+
if (contextStats.tokens == null || contextStats.contextWindow == null) return null;
|
|
502
|
+
const pct = Math.min(100, contextStats.percent ?? 0);
|
|
503
|
+
const bar = makeProgressBar(pct, 16);
|
|
504
|
+
const usedK = Math.round(contextStats.tokens / 1000);
|
|
505
|
+
const totalK = Math.round(contextStats.contextWindow / 1000);
|
|
506
|
+
return t.fg("muted", "Ctx: ") + t.fg("dim", `${bar} ${usedK}k/${totalK}k`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Session ──
|
|
510
|
+
case "cost": {
|
|
511
|
+
return t.fg("muted", "Cost: ") + t.fg("dim", fmtCost(tokenStats.cost));
|
|
512
|
+
}
|
|
513
|
+
case "session-clock": {
|
|
514
|
+
const elapsed = Date.now() - sessionStart;
|
|
515
|
+
return t.fg("muted", "Session: ") + t.fg("dim", fmtDuration(elapsed));
|
|
516
|
+
}
|
|
517
|
+
case "session-turns": {
|
|
518
|
+
return t.fg("muted", "Turns: ") + t.fg("dim", String(tokenStats.assistantTurns));
|
|
519
|
+
}
|
|
520
|
+
case "session-name": {
|
|
521
|
+
if (!sessionName) return null;
|
|
522
|
+
return t.fg("muted", "Session: ") + t.fg("dim", sessionName);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── Environment ──
|
|
526
|
+
case "cwd": {
|
|
527
|
+
const home = homedir();
|
|
528
|
+
const dir = cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
529
|
+
return t.fg("dim", dir);
|
|
530
|
+
}
|
|
531
|
+
case "memory": {
|
|
532
|
+
try {
|
|
533
|
+
const { totalmem, freemem, platform } = require("node:os");
|
|
534
|
+
const total = totalmem();
|
|
535
|
+
let used: number;
|
|
536
|
+
if (platform() === "darwin") {
|
|
537
|
+
try {
|
|
538
|
+
const vmstat = execSync("vm_stat", { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).toString();
|
|
539
|
+
const psMatch = /page size of (\d+)/.exec(vmstat);
|
|
540
|
+
const pageSize = psMatch ? parseInt(psMatch[1]) : 16384;
|
|
541
|
+
const actMatch = /Pages active:\s+(\d+)/.exec(vmstat);
|
|
542
|
+
const wiredMatch = /Pages wired down:\s+(\d+)/.exec(vmstat);
|
|
543
|
+
const act = actMatch ? parseInt(actMatch[1]) : 0;
|
|
544
|
+
const wired = wiredMatch ? parseInt(wiredMatch[1]) : 0;
|
|
545
|
+
used = (act + wired) * pageSize;
|
|
546
|
+
} catch { used = total - freemem(); }
|
|
547
|
+
} else { used = total - freemem(); }
|
|
548
|
+
return t.fg("muted", "Mem: ") + t.fg("dim", `${fmtBytes(used)}/${fmtBytes(total)}`);
|
|
549
|
+
} catch { return null; }
|
|
550
|
+
}
|
|
551
|
+
case "terminal-width": {
|
|
552
|
+
return t.fg("muted", "Term: ") + t.fg("dim", String(process.stdout.columns ?? "?"));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
default:
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ─── 主插件 ───────────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
export default function (pi: ExtensionAPI) {
|
|
563
|
+
let config = loadConfig();
|
|
564
|
+
let sessionStart = Date.now();
|
|
565
|
+
let speedData = { totalMs: 0, inputTokens: 0, outputTokens: 0 };
|
|
566
|
+
|
|
567
|
+
// 当 /statusline 命令触发后,下一轮 before_agent_start 注入配置上下文
|
|
568
|
+
let pendingConfigRequest: string | null = null;
|
|
569
|
+
|
|
570
|
+
// ── footer 安装 ─────────────────────────────────────────────────────────────
|
|
571
|
+
function installFooter(ctx: ExtensionContext) {
|
|
572
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
573
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
574
|
+
return {
|
|
575
|
+
dispose: unsub,
|
|
576
|
+
invalidate() {},
|
|
577
|
+
render(width: number): string[] {
|
|
578
|
+
const branch = footerData.getGitBranch();
|
|
579
|
+
const ra: RenderArgs = {
|
|
580
|
+
ctx,
|
|
581
|
+
theme,
|
|
582
|
+
sessionStart,
|
|
583
|
+
speedData,
|
|
584
|
+
tokenStats: getSessionTokenStats(ctx),
|
|
585
|
+
contextStats: getContextStats(ctx),
|
|
586
|
+
gitStats: getGitStats(ctx.cwd, branch),
|
|
587
|
+
thinkingLevel: pi.getThinkingLevel?.() ?? null,
|
|
588
|
+
sessionName: pi.getSessionName?.() ?? null,
|
|
589
|
+
};
|
|
590
|
+
const sep = theme.fg("dim", " | ");
|
|
591
|
+
const lines = config.rows
|
|
592
|
+
.map((row) => row.map((id) => renderWidget(id, ra)).filter((s): s is string => s != null))
|
|
593
|
+
.filter((parts) => parts.length > 0)
|
|
594
|
+
.map((parts) => truncateToWidth(parts.join(sep), width));
|
|
595
|
+
|
|
596
|
+
if (lines.length === 0) return [theme.fg("dim", "(no widgets — /statusline to configure)")];
|
|
597
|
+
return lines;
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── 生命周期 ────────────────────────────────────────────────────────────────
|
|
604
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
605
|
+
sessionStart = Date.now();
|
|
606
|
+
speedData = { totalMs: 0, inputTokens: 0, outputTokens: 0 };
|
|
607
|
+
config = loadConfig();
|
|
608
|
+
installFooter(ctx);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
612
|
+
sessionStart = Date.now();
|
|
613
|
+
speedData = { totalMs: 0, inputTokens: 0, outputTokens: 0 };
|
|
614
|
+
installFooter(ctx);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
pi.on("agent_end", async (event, _ctx) => {
|
|
618
|
+
for (const msg of event.messages) {
|
|
619
|
+
if (msg.role === "assistant") {
|
|
620
|
+
const m = msg as AssistantMessage;
|
|
621
|
+
speedData.inputTokens += m.usage.input;
|
|
622
|
+
speedData.outputTokens += m.usage.output;
|
|
623
|
+
speedData.totalMs += (m.usage.output / 50) * 1000;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// ── configure_statusline 工具:LLM 理解自然语言后调用此工具更新配置 ─────────────
|
|
629
|
+
const { Type } = require("@sinclair/typebox");
|
|
630
|
+
|
|
631
|
+
pi.registerTool({
|
|
632
|
+
name: "configure_statusline",
|
|
633
|
+
label: "Configure Status Line",
|
|
634
|
+
description:
|
|
635
|
+
"Update the pi status line widgets and layout rows. " +
|
|
636
|
+
"Use this when the user asks to configure the status line footer, including one-line/two-line/three-line layouts. " +
|
|
637
|
+
"Available widget IDs: " + Object.keys(ALL_WIDGETS).join(", ") + ". " +
|
|
638
|
+
"Available presets: " + Object.keys(LAYOUT_PRESETS).join(", "),
|
|
639
|
+
parameters: Type.Object({
|
|
640
|
+
widgets: Type.Optional(Type.Array(
|
|
641
|
+
Type.String({ description: "A valid widget ID" }),
|
|
642
|
+
{ description: "Ordered list of widget IDs to show in a single-row status line" }
|
|
643
|
+
)),
|
|
644
|
+
rows: Type.Optional(Type.Array(
|
|
645
|
+
Type.Array(Type.String({ description: "A valid widget ID" })),
|
|
646
|
+
{ description: "Ordered rows of widget IDs for a multi-line status line" }
|
|
647
|
+
)),
|
|
648
|
+
preset: Type.Optional(Type.String({ description: "Optional preset id, e.g. two-line-balanced" })),
|
|
649
|
+
}),
|
|
650
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
651
|
+
let nextRows: WidgetId[][] = [];
|
|
652
|
+
let invalid: string[] = [];
|
|
653
|
+
let source = "custom";
|
|
654
|
+
|
|
655
|
+
const preset = typeof params.preset === "string" ? params.preset : undefined;
|
|
656
|
+
if (preset && preset in LAYOUT_PRESETS) {
|
|
657
|
+
nextRows = LAYOUT_PRESETS[preset as PresetId].rows.map((row) => [...row]);
|
|
658
|
+
source = `preset:${preset}`;
|
|
659
|
+
} else if (Array.isArray(params.rows)) {
|
|
660
|
+
invalid = (params.rows as string[][]).flat().filter((w) => !(w in ALL_WIDGETS));
|
|
661
|
+
nextRows = normalizeRows(params.rows);
|
|
662
|
+
} else if (Array.isArray(params.widgets)) {
|
|
663
|
+
invalid = (params.widgets as string[]).filter((w) => !(w in ALL_WIDGETS));
|
|
664
|
+
const valid = (params.widgets as string[]).filter((w) => w in ALL_WIDGETS) as WidgetId[];
|
|
665
|
+
if (valid.length > 0) nextRows = [valid];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (nextRows.length === 0) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`No valid status line configuration provided. Available widgets: ${Object.keys(ALL_WIDGETS).join(", ")}. ` +
|
|
671
|
+
`Available presets: ${Object.keys(LAYOUT_PRESETS).join(", ")}`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
config = { rows: nextRows };
|
|
676
|
+
saveConfig(config);
|
|
677
|
+
installFooter(ctx);
|
|
678
|
+
|
|
679
|
+
const msg =
|
|
680
|
+
`✓ Status line updated (${source}): ${formatRows(nextRows)}` +
|
|
681
|
+
(invalid.length > 0 ? `\n(skipped unknown IDs: ${invalid.join(", ")})` : "");
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
content: [{ type: "text" as const, text: msg }],
|
|
685
|
+
details: { rows: nextRows, widgets: rowsToWidgets(nextRows), preset: preset ?? null },
|
|
686
|
+
};
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// ── before_agent_start:有待处理配置请求时注入 system prompt ────────────────────
|
|
691
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
692
|
+
if (!pendingConfigRequest) return undefined;
|
|
693
|
+
|
|
694
|
+
const request = pendingConfigRequest;
|
|
695
|
+
pendingConfigRequest = null;
|
|
696
|
+
|
|
697
|
+
const widgetList = Object.entries(ALL_WIDGETS)
|
|
698
|
+
.map(([id, info]) => ` ${id}: ${info.desc}`)
|
|
699
|
+
.join("\n");
|
|
700
|
+
const presetList = Object.entries(LAYOUT_PRESETS)
|
|
701
|
+
.map(([id, info]) => ` ${id}: ${info.desc}`)
|
|
702
|
+
.join("\n");
|
|
703
|
+
|
|
704
|
+
const injection =
|
|
705
|
+
`\n\n=== STATUS LINE CONFIGURATION ===\n` +
|
|
706
|
+
`The user invoked /statusline to configure the footer. ` +
|
|
707
|
+
`You MUST call the \`configure_statusline\` tool to apply the configuration.\n\n` +
|
|
708
|
+
`Available widget IDs:\n${widgetList}\n\n` +
|
|
709
|
+
`Available presets:\n${presetList}\n\n` +
|
|
710
|
+
`Current layout: ${formatRows(config.rows)}\n\n` +
|
|
711
|
+
`User's request: "${request}"\n\n` +
|
|
712
|
+
`Instructions:\n` +
|
|
713
|
+
`1. Interpret the user's request semantically (any language, any phrasing); do not rely on keyword lookup\n` +
|
|
714
|
+
`2. If the user asks for a preset-like layout (single-line / two-line / three-line, compact, balanced, detailed), prefer using preset\n` +
|
|
715
|
+
`3. If the user describes exact row content, call configure_statusline with rows\n` +
|
|
716
|
+
`4. If the user only lists widgets without row grouping, call configure_statusline with widgets\n` +
|
|
717
|
+
`5. Briefly confirm what was configured (one sentence)\n` +
|
|
718
|
+
`=== END ===`;
|
|
719
|
+
|
|
720
|
+
return { systemPrompt: event.systemPrompt + injection };
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// ── /statusline 命令 ────────────────────────────────────────────────────────
|
|
724
|
+
pi.registerCommand("statusline", {
|
|
725
|
+
description: "用自然语言配置 status line 显示的条目",
|
|
726
|
+
handler: async (args, ctx) => {
|
|
727
|
+
const trimmed = (args ?? "").trim();
|
|
728
|
+
|
|
729
|
+
// 无参数:显示帮助
|
|
730
|
+
if (!trimmed) {
|
|
731
|
+
const byCategory: Record<string, string[]> = {};
|
|
732
|
+
for (const [id, info] of Object.entries(ALL_WIDGETS)) {
|
|
733
|
+
const cat = info.category;
|
|
734
|
+
if (!byCategory[cat]) byCategory[cat] = [];
|
|
735
|
+
byCategory[cat].push(` ${id.padEnd(18)} ${info.desc}`);
|
|
736
|
+
}
|
|
737
|
+
const lines = [
|
|
738
|
+
`当前配置: ${formatRows(config.rows)}`,
|
|
739
|
+
"",
|
|
740
|
+
"可用 preset:",
|
|
741
|
+
...Object.entries(LAYOUT_PRESETS).map(([id, info]) => ` ${id.padEnd(20)} ${info.desc}`),
|
|
742
|
+
"",
|
|
743
|
+
"自然语言示例:",
|
|
744
|
+
" /statusline 切成单排平衡布局",
|
|
745
|
+
" /statusline 改成双排,第一排看模型和 git,第二排看 token、费用和时长",
|
|
746
|
+
" /statusline 详细一点,分三排,最后一排放今天、本月和 session 时长",
|
|
747
|
+
"",
|
|
748
|
+
"可用 widget:",
|
|
749
|
+
...Object.entries(byCategory).flatMap(([cat, ws]) => [`[${cat}]`, ...ws]),
|
|
750
|
+
"",
|
|
751
|
+
"直接用自然语言描述你想要的内容,例如:",
|
|
752
|
+
" /statusline 切成双排平衡布局",
|
|
753
|
+
" /statusline 紧凑布局",
|
|
754
|
+
" /statusline 改成三排详细布局",
|
|
755
|
+
" /statusline 两排,第一排模型、分支、上下文,第二排费用、in/out、today、month、时长",
|
|
756
|
+
" /statusline 第一排模型、分支、上下文,第二排 today、month、cost、时长",
|
|
757
|
+
" /statusline show git branch, cost, and context usage",
|
|
758
|
+
" /statusline reset",
|
|
759
|
+
];
|
|
760
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// reset
|
|
765
|
+
if (/^(reset|重置|默认|default)$/i.test(trimmed)) {
|
|
766
|
+
config = { rows: DEFAULT_ROWS.map((row) => [...row]) };
|
|
767
|
+
saveConfig(config);
|
|
768
|
+
installFooter(ctx);
|
|
769
|
+
ctx.ui.notify(`✓ 已重置为默认: ${formatRows(config.rows)}`, "info");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// 其他配置统一交给 LLM 做自然语言理解,避免本地关键词猜测误判
|
|
774
|
+
// 设置待处理标记,下一轮 before_agent_start 会注入 system prompt
|
|
775
|
+
pendingConfigRequest = trimmed;
|
|
776
|
+
|
|
777
|
+
// 把用户原话发给 LLM,LLM 会根据注入的 system prompt 调用 configure_statusline 工具
|
|
778
|
+
pi.sendUserMessage(trimmed, { deliverAs: "followUp" });
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhangweiii/pi-status-line",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Natural-language configurable status line extension for pi.",
|
|
5
|
+
"author": "zhangweiii",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/zhangweiii/pi-status-line.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/zhangweiii/pi-status-line#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/zhangweiii/pi-status-line/issues"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"extensions",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"package.json"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./extensions"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@mariozechner/pi-ai": "*",
|
|
35
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
36
|
+
"@mariozechner/pi-tui": "*",
|
|
37
|
+
"@sinclair/typebox": "*"
|
|
38
|
+
}
|
|
39
|
+
}
|