@cnife/pi-footer 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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @cnife/pi-footer
2
+
3
+ 个人专属 pi footer。两行布局,全 dim,仅 ASCII + Unicode(无 nerd font),无配置。
4
+
5
+ > 个人扩展。安装:`pi install npm:@cnife/pi-footer`
6
+
7
+ ## 布局
8
+
9
+ ```text
10
+ <目录> · <分支> ↑<ahead> ↓<behind> *<未提交数> · <会话名>
11
+ <provider>/<model_id> · <思考强度> · <已用>/<总量> (<百分比>) · $<花费>
12
+ ```
13
+
14
+ - 各项 ` · ` 分隔,空值自动省略(无 git 段、无会话名、cost=0 等均不显示)
15
+ - git:`main ↑1 ↓2 *3`,ahead/behind/未提交数为 0 时隐藏
16
+ - cost:`<0.01` 用 3 位小数,否则 2 位;为 0 时整项隐藏
17
+ - token:`<1000` 原值,`<1M` 用 `X.XK`,`≥1M` 用 `X.XM`
18
+
19
+ ## 刷新
20
+
21
+ - git 状态:10s 定时器 + `tool_result`(agent 工具执行后即时)+ `user_bash`(`!`/`!!` 命令后 1.5s debounce)+ `onBranchChange`
22
+ - 其余字段随 pi 内部状态自动重渲染
23
+
24
+ ## 字符约束
25
+
26
+ 仅 ASCII + Unicode 基本平面字符(`· ↑ ↓ *`),不依赖 nerd font。
@@ -0,0 +1,206 @@
1
+ /**
2
+ * cnife-footer - 个人专属 pi footer。
3
+ * 两行:第一行工作区(目录 · git分支 ↑ahead ↓behind *未提交 · 会话名),
4
+ * 第二行模型(provider/id · thinking · ctx · $cost)。
5
+ * 全 dim,仅 ASCII + Unicode,无 nerd font,无配置。
6
+ */
7
+
8
+ import { basename } from "node:path";
9
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { truncateToWidth } from "@earendil-works/pi-tui";
12
+
13
+ interface GitState {
14
+ branch: string | null;
15
+ ahead: number;
16
+ behind: number;
17
+ dirty: number;
18
+ }
19
+
20
+ const SEP = " · ";
21
+ const GIT_INTERVAL_MS = 10_000;
22
+ const BASH_REFRESH_DELAY_MS = 1500;
23
+
24
+ function fmt(n: number): string {
25
+ if (n < 1000) return String(n);
26
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
27
+ return `${(n / 1_000_000).toFixed(1)}M`;
28
+ }
29
+
30
+ function fmtCost(c: number): string {
31
+ return c < 0.01 ? `$${c.toFixed(3)}` : `$${c.toFixed(2)}`;
32
+ }
33
+
34
+ /** Parse `git status -b --porcelain` output into branch/ahead/behind/dirty. */
35
+ function parseGitStatus(stdout: string): GitState {
36
+ const lines = stdout.split("\n");
37
+ const header = lines[0] ?? "";
38
+ let branch: string | null = null;
39
+ let ahead = 0;
40
+ let behind = 0;
41
+
42
+ // Header forms:
43
+ // ## main
44
+ // ## main...origin/main
45
+ // ## main...origin/main [ahead 1]
46
+ // ## main...origin/main [ahead 1, behind 2]
47
+ // ## main...origin/main [gone]
48
+ // ## HEAD (no branch) -> detached
49
+ // ## No commits yet on main
50
+ const m = header.match(
51
+ /^## (?:HEAD \(no branch\)|No commits yet on (\S+)|(.+?)(?:\.\.\.\S+)?(?: \[(.+)\])?)$/,
52
+ );
53
+ if (m) {
54
+ branch = m[1] ?? m[2] ?? null;
55
+ const bracket = m[3];
56
+ if (bracket) {
57
+ const a = bracket.match(/ahead (\d+)/);
58
+ const b = bracket.match(/behind (\d+)/);
59
+ if (a) ahead = Number(a[1]);
60
+ if (b) behind = Number(b[1]);
61
+ }
62
+ }
63
+
64
+ let dirty = 0;
65
+ for (let i = 1; i < lines.length; i++) {
66
+ const l = lines[i];
67
+ if (l && !l.startsWith("##")) dirty++;
68
+ }
69
+
70
+ return { branch, ahead, behind, dirty };
71
+ }
72
+
73
+ export default function (pi: ExtensionAPI) {
74
+ let gitState: GitState = { branch: null, ahead: 0, behind: 0, dirty: 0 };
75
+ let activeTui: { requestRender(): void } | undefined;
76
+ let timer: ReturnType<typeof setInterval> | undefined;
77
+ let bashRefreshTimer: ReturnType<typeof setTimeout> | undefined;
78
+ let refreshing = false;
79
+ let cwd = "";
80
+
81
+ const refreshGit = async () => {
82
+ if (refreshing) return;
83
+ refreshing = true;
84
+ try {
85
+ const result = await pi
86
+ .exec("git", ["status", "-b", "--porcelain"], { cwd })
87
+ .catch(() => undefined);
88
+ if (result === undefined) {
89
+ gitState = { branch: null, ahead: 0, behind: 0, dirty: 0 };
90
+ } else {
91
+ gitState = parseGitStatus(result.stdout);
92
+ }
93
+ activeTui?.requestRender();
94
+ } finally {
95
+ refreshing = false;
96
+ }
97
+ };
98
+
99
+ pi.on("session_start", async (_event, ctx) => {
100
+ cwd = ctx.cwd;
101
+ gitState = { branch: null, ahead: 0, behind: 0, dirty: 0 };
102
+
103
+ ctx.ui.setFooter((tui, theme, footerData) => {
104
+ activeTui = tui;
105
+ const unsub = footerData.onBranchChange(() => {
106
+ void refreshGit();
107
+ });
108
+
109
+ const renderLine = (parts: string[], width: number): string => {
110
+ const joined = parts.filter((p) => p.length > 0).join(SEP);
111
+ return truncateToWidth(
112
+ theme.fg("dim", joined),
113
+ width,
114
+ theme.fg("dim", "..."),
115
+ );
116
+ };
117
+
118
+ return {
119
+ dispose() {
120
+ unsub();
121
+ },
122
+ invalidate() {},
123
+ render(width: number): string[] {
124
+ try {
125
+ // Line 1: dir · [branch ↑a ↓b *d] · session
126
+ const dir = basename(cwd) || cwd;
127
+ const gitParts: string[] = [];
128
+ if (gitState.branch) {
129
+ gitParts.push(gitState.branch);
130
+ if (gitState.ahead > 0) gitParts.push(`↑${gitState.ahead}`);
131
+ if (gitState.behind > 0) gitParts.push(`↓${gitState.behind}`);
132
+ if (gitState.dirty > 0) gitParts.push(`*${gitState.dirty}`);
133
+ }
134
+ const gitStr = gitParts.join(" ");
135
+ const session = pi.getSessionName() ?? "";
136
+ const line1 = renderLine([dir, gitStr, session], width);
137
+
138
+ // Line 2: provider/id · thinking · ctx · $cost (cost=0 隐藏)
139
+ const model = ctx.model
140
+ ? `${ctx.model.provider}/${ctx.model.id}`
141
+ : "no-model";
142
+ const thinking = pi.getThinkingLevel();
143
+
144
+ let cost = 0;
145
+ for (const e of ctx.sessionManager.getEntries()) {
146
+ if (e.type === "message" && e.message.role === "assistant") {
147
+ cost += (e.message as AssistantMessage).usage.cost.total;
148
+ }
149
+ }
150
+
151
+ const usage = ctx.getContextUsage();
152
+ let ctxStr: string;
153
+ if (usage?.contextWindow) {
154
+ const used = usage.tokens ?? 0;
155
+ const pctStr =
156
+ usage.percent !== null && usage.percent !== undefined
157
+ ? `${usage.percent.toFixed(1)}%`
158
+ : "?";
159
+ ctxStr = `${fmt(used)}/${fmt(usage.contextWindow)} (${pctStr})`;
160
+ } else {
161
+ ctxStr = "?";
162
+ }
163
+
164
+ const costStr = cost > 0 ? fmtCost(cost) : "";
165
+ const line2 = renderLine([model, thinking, ctxStr, costStr], width);
166
+
167
+ return [line1, line2];
168
+ } catch {
169
+ return [theme.fg("dim", "footer error")];
170
+ }
171
+ },
172
+ };
173
+ });
174
+
175
+ void refreshGit();
176
+ timer = setInterval(() => {
177
+ void refreshGit();
178
+ }, GIT_INTERVAL_MS);
179
+ });
180
+
181
+ // agent 工具执行后立即刷新 git
182
+ pi.on("tool_result", async () => {
183
+ void refreshGit();
184
+ });
185
+
186
+ // `!`/`!!` 命令执行后延迟刷新(debounce,等命令执行完)
187
+ pi.on("user_bash", () => {
188
+ if (bashRefreshTimer) clearTimeout(bashRefreshTimer);
189
+ bashRefreshTimer = setTimeout(() => {
190
+ bashRefreshTimer = undefined;
191
+ void refreshGit();
192
+ }, BASH_REFRESH_DELAY_MS);
193
+ });
194
+
195
+ pi.on("session_shutdown", () => {
196
+ if (timer) {
197
+ clearInterval(timer);
198
+ timer = undefined;
199
+ }
200
+ if (bashRefreshTimer) {
201
+ clearTimeout(bashRefreshTimer);
202
+ bashRefreshTimer = undefined;
203
+ }
204
+ activeTui = undefined;
205
+ });
206
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@cnife/pi-footer",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "description": "个人专属 pi footer:两行工作区/模型状态,全 dim,仅 ASCII+Unicode",
9
+ "homepage": "https://github.com/CNife/pi-extensions#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/CNife/pi-extensions/issues"
12
+ },
13
+ "license": "MIT",
14
+ "author": "CNife <CNife@vip.qq.com>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/CNife/pi-extensions.git"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "./extensions"
25
+ ]
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-coding-agent": "*"
29
+ }
30
+ }