pi-cometix-footer 1.0.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,57 @@
1
+ # pi-cometix-footer
2
+
3
+ A single-line, [CCometixLine](https://github.com/Haleclipse/CCometixLine) "cometix"-style footer for [pi](https://pi.dev).
4
+
5
+ ```
6
+ π GLM-5.2 • xhigh | ~/path | master ✓ | 4% 13k/272k | ↑26k ↓2.1k CH96.8%
7
+ ```
8
+
9
+ Segments (left to right, ` | ` separators, bold colored, Nerd Font icons):
10
+
11
+ | Segment | Source | Color |
12
+ |---|---|---|
13
+ | Model `• thinking` | `ctx.model.name` + `pi.getThinkingLevel()` (pi thinking palette) | cyan (+ level color) |
14
+ | Directory | `ctx.sessionManager.getCwd()` (`~`-relative) | yellow icon / green text |
15
+ | Git `branch ✓/●/⚠ ↑n/↓n` | `footerData.getGitBranch()` + `git status -b --porcelain=v1` | blue |
16
+ | Context `pct tokens/window` | `ctx.getContextUsage()` | magenta (>70 yellow, >90 red) |
17
+ | Tokens `↑in ↓out CH%` | cumulative `usage` across session; cache hit rate from last assistant msg | cyan |
18
+
19
+ > Looks are borrowed from CCometixLine (MIT, Haleclipse). This package is an independent pi extension; code is its own.
20
+
21
+ ## Install
22
+
23
+ From a local path:
24
+
25
+ ```bash
26
+ pi install ./cometix-footer
27
+ ```
28
+
29
+ From git (push this folder to a repo first):
30
+
31
+ ```bash
32
+ pi install git:github.com/<you>/pi-cometix-footer
33
+ ```
34
+
35
+ From npm (publish first):
36
+
37
+ ```bash
38
+ pi install npm:pi-cometix-footer
39
+ ```
40
+
41
+ Then `/reload` in pi. Toggle on/off with the `/cometix-footer` command.
42
+
43
+ > If you previously kept the loose file at `~/.pi/agent/extensions/cometix-footer.ts`, remove it before installing the package to avoid double-loading.
44
+
45
+ ## Customize
46
+
47
+ Edit `extensions/cometix-footer.ts`, then `/reload`. Notable knobs at the top:
48
+
49
+ - `ICON_MODE: "nerd" | "emoji"` — switch icon set if your terminal has no Nerd Font.
50
+ - `ICONS.nerd.*` — per-segment Nerd Font codepoints (see <https://www.nerdfonts.com/cheat-sheet>).
51
+ - `C.*` — 16-color SGR codes per segment.
52
+ - `GIT_TTL` — git status refresh interval (ms).
53
+
54
+ ## Requirements
55
+
56
+ - pi (provides `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` at runtime; listed as peerDependencies).
57
+ - A Nerd Font in your terminal for icons (or set `ICON_MODE = "emoji"`).
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Cometix-style footer for pi.
3
+ *
4
+ * Borrows the "cometix" theme look from CCometixLine (MIT, Haleclipse):
5
+ * https://github.com/Haleclipse/CCometixLine
6
+ *
7
+ * Single line, " | " separators, Nerd Font icons, bold colored segments:
8
+ * Model | Directory | Git(branch + ✓/●/⚠ + ↑n/↓n) | Context% | Tokens | Cost
9
+ *
10
+ * Toggle with /cometix-footer (on by default). /reload to pick up edits.
11
+ */
12
+
13
+ import type { ExtensionAPI, ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
14
+ import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
15
+ import { isAbsolute, relative, resolve, sep } from "node:path";
16
+
17
+ // --- icon set ---------------------------------------------------------------
18
+ // Set to "emoji" if your terminal has no Nerd Font (icons become 🤖 📁 🌿 ⚡ 📊 💰).
19
+ const ICON_MODE: "nerd" | "emoji" = "nerd";
20
+
21
+ const cp = (n: number) => String.fromCodePoint(n);
22
+ const ICONS = {
23
+ nerd: {
24
+ model: "\ue22c", // nf-fae-pi
25
+ dir: "\ue285", // nf-fae-bigger
26
+ git: cp(0xf02a2), // nf-md-git
27
+ ctx: "\uf49b", // nf-md-counter
28
+ usage: cp(0xf0a9e), // nf-md-chart_bar
29
+ cost: "\ueec1", // nf-md-cash
30
+ },
31
+ emoji: {
32
+ model: "🤖",
33
+ dir: "📁",
34
+ git: "🌿",
35
+ ctx: "⚡️",
36
+ usage: "📊",
37
+ cost: "💰",
38
+ },
39
+ }[ICON_MODE];
40
+
41
+ // --- ANSI helpers (truecolor terminal) --------------------------------------
42
+ const RESET = "\x1b[0m";
43
+ // bold + color, then reset
44
+ const paint = (code: number, s: string) => `\x1b[1;${code}m${s}${RESET}`;
45
+ const SEG = `\x1b[2m | ${RESET}`; // dim separator
46
+
47
+ // 16-color bright codes used by the cometix theme
48
+ const C = {
49
+ cyan: 96, // model, usage
50
+ yellow: 93, // dir icon
51
+ green: 92, // dir text
52
+ blue: 94, // git
53
+ magenta: 95, // context
54
+ cost: 33, // cost (yellow, normal)
55
+ red: 91,
56
+ warn: 93,
57
+ };
58
+
59
+ // --- formatters -------------------------------------------------------------
60
+ function fmtCwd(cwd: string, home: string | undefined): string {
61
+ if (!home) return cwd;
62
+ const r = relative(resolve(home), resolve(cwd));
63
+ if (r === "") return "~";
64
+ if (r === ".." || r.startsWith(`..${sep}`) || isAbsolute(r)) return cwd;
65
+ return `~${sep}${r}`;
66
+ }
67
+
68
+ function fmtTok(n: number): string {
69
+ if (n < 1000) return String(n);
70
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
71
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
72
+ if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
73
+ return `${Math.round(n / 1_000_000)}M`;
74
+ }
75
+
76
+ interface GitStatus {
77
+ dirty: boolean;
78
+ conflicts: boolean;
79
+ ahead: number;
80
+ behind: number;
81
+ }
82
+
83
+ function parseGitPorcelain(out: string): GitStatus {
84
+ const s: GitStatus = { dirty: false, conflicts: false, ahead: 0, behind: 0 };
85
+ for (const line of out.split("\n")) {
86
+ if (line.startsWith("## ")) {
87
+ const m = line.match(/\[(?:ahead (\d+)(?:,? behind (\d+))?|behind (\d+)(?:,? ahead (\d+))?)\]/);
88
+ if (m) {
89
+ s.ahead = Number(m[1] ?? m[4] ?? 0);
90
+ s.behind = Number(m[2] ?? m[3] ?? 0);
91
+ }
92
+ } else if (line.length >= 2) {
93
+ const xy = line.slice(0, 2);
94
+ if (xy === "!!" || xy === "??") {
95
+ s.dirty = true;
96
+ } else if (/^(UU|AA|DD|AU|UA|DU|UD)$/.test(xy)) {
97
+ s.conflicts = true;
98
+ s.dirty = true;
99
+ } else {
100
+ s.dirty = true;
101
+ }
102
+ }
103
+ }
104
+ return s;
105
+ }
106
+
107
+ // --- extension --------------------------------------------------------------
108
+ export default function (pi: ExtensionAPI) {
109
+ // user preference: on by default. Toggle with /cometix-footer.
110
+ let userEnabled = true;
111
+ let timer: ReturnType<typeof setInterval> | undefined;
112
+ let unsubBranch: (() => void) | undefined;
113
+
114
+ // git status cache, refreshed async; render reads it sync
115
+ let gitCache: { ts: number; data: GitStatus } = {
116
+ ts: 0,
117
+ data: { dirty: false, conflicts: false, ahead: 0, behind: 0 },
118
+ };
119
+ let gitInFlight = false;
120
+ const GIT_TTL = 3000;
121
+
122
+ async function refreshGit(cwd: string, branch: string | null) {
123
+ if (gitInFlight) return;
124
+ if (!branch) {
125
+ gitCache = { ts: Date.now(), data: { dirty: false, conflicts: false, ahead: 0, behind: 0 } };
126
+ return;
127
+ }
128
+ gitInFlight = true;
129
+ try {
130
+ const r = await pi.exec("git", ["status", "-b", "--porcelain=v1"], { cwd, timeout: 3000 });
131
+ const data = r.code === 0 ? parseGitPorcelain(r.stdout) : gitCache.data;
132
+ gitCache = { ts: Date.now(), data };
133
+ } catch {
134
+ // keep previous cache
135
+ } finally {
136
+ gitInFlight = false;
137
+ }
138
+ }
139
+
140
+ function installFooter(ctx: any): void {
141
+ // clean up any previous instance first
142
+ if (timer) clearInterval(timer);
143
+ timer = undefined;
144
+ unsubBranch?.();
145
+ unsubBranch = undefined;
146
+
147
+ ctx.ui.setFooter((tui: TUI, theme: any, footerData: ReadonlyFooterDataProvider) => {
148
+ // refresh on git branch / HEAD change
149
+ unsubBranch = footerData.onBranchChange(() => {
150
+ void refreshGit(ctx.cwd, footerData.getGitBranch());
151
+ tui.requestRender();
152
+ });
153
+ // periodic refresh for dirty / ahead / behind
154
+ timer = setInterval(() => {
155
+ void refreshGit(ctx.cwd, footerData.getGitBranch()).then(() => tui.requestRender());
156
+ }, GIT_TTL);
157
+
158
+ return {
159
+ invalidate() {},
160
+ dispose() {
161
+ if (timer) clearInterval(timer);
162
+ timer = undefined;
163
+ unsubBranch?.();
164
+ unsubBranch = undefined;
165
+ },
166
+ render(width: number): string[] {
167
+ // trigger async refresh if stale (non-blocking)
168
+ const now = Date.now();
169
+ if (now - gitCache.ts > GIT_TTL) {
170
+ void refreshGit(ctx.cwd, footerData.getGitBranch()).then(() => tui.requestRender());
171
+ }
172
+
173
+ const home = process.env.HOME || process.env.USERPROFILE;
174
+
175
+ // model (+ thinking level, like pi's native "gpt-5.5 • xhigh")
176
+ const modelId = ctx.model?.name || ctx.model?.id || "no-model";
177
+ const lvl = pi.getThinkingLevel();
178
+ const showLvl = !!ctx.model?.reasoning && !!lvl && lvl !== "off";
179
+ let modelSeg: string;
180
+ if (showLvl) {
181
+ // color the level with pi's thinking palette (matches editor border)
182
+ const lvlToken = `thinking${lvl.charAt(0).toUpperCase()}${lvl.slice(1)}`;
183
+ const lvlStr = theme.fg(lvlToken, lvl);
184
+ modelSeg = `\x1b[1;${C.cyan}m${ICONS.model} ${modelId}${RESET}\x1b[2m • ${RESET}${lvlStr}${RESET}`;
185
+ } else {
186
+ modelSeg = paint(C.cyan, `${ICONS.model} ${modelId}`);
187
+ }
188
+
189
+ // directory
190
+ const dirText = fmtCwd(ctx.sessionManager.getCwd(), home);
191
+ const dirSeg = `\x1b[1;${C.yellow}m${ICONS.dir} \x1b[${C.green}m${dirText}${RESET}`;
192
+
193
+ // git
194
+ const branch = footerData.getGitBranch();
195
+ let gitSeg = "";
196
+ if (branch) {
197
+ const g = gitCache.data;
198
+ let st = " ✓";
199
+ if (g.conflicts) st = " ⚠";
200
+ else if (g.dirty) st = " ●";
201
+ let remote = "";
202
+ if (g.ahead > 0) remote += ` ↑${g.ahead}`;
203
+ if (g.behind > 0) remote += ` ↓${g.behind}`;
204
+ gitSeg = paint(C.blue, `${ICONS.git} ${branch}${st}${remote}`);
205
+ }
206
+
207
+ // context window: e.g. "4% 13k/272k"
208
+ const cu = ctx.getContextUsage();
209
+ const pct = cu?.percent;
210
+ const pctStr = pct != null ? `${Math.round(pct)}%` : "?";
211
+ const tokStr = cu?.tokens != null ? fmtTok(cu.tokens) : "?";
212
+ const winStr = cu?.contextWindow ? fmtTok(cu.contextWindow) : "?";
213
+ const ctxColor = pct == null ? C.magenta : pct > 90 ? C.red : pct > 70 ? C.warn : C.magenta;
214
+ const ctxSeg = paint(ctxColor, `${ICONS.ctx} ${pctStr} ${tokStr}/${winStr}`);
215
+
216
+ // tokens (cumulative across the session file) + latest cache hit rate
217
+ let tin = 0;
218
+ let tout = 0;
219
+ let totalCR = 0;
220
+ let totalCW = 0;
221
+ let lastHit: number | undefined;
222
+ for (const e of ctx.sessionManager.getEntries()) {
223
+ if (e?.type === "message" && e.message?.role === "assistant") {
224
+ const u = (e.message as any).usage;
225
+ if (u) {
226
+ tin += u.input ?? 0;
227
+ tout += u.output ?? 0;
228
+ const cr = u.cacheRead ?? 0;
229
+ const cw = u.cacheWrite ?? 0;
230
+ totalCR += cr;
231
+ totalCW += cw;
232
+ const prompt = (u.input ?? 0) + cr + cw;
233
+ if (prompt > 0) lastHit = (cr / prompt) * 100;
234
+ }
235
+ }
236
+ }
237
+ let tokText = `${ICONS.usage} ↑${fmtTok(tin)} ↓${fmtTok(tout)}`;
238
+ if ((totalCR > 0 || totalCW > 0) && lastHit != null) {
239
+ tokText += ` CH${lastHit.toFixed(1)}%`;
240
+ }
241
+ const tokSeg = paint(C.cyan, tokText);
242
+
243
+ const segs = [modelSeg, dirSeg];
244
+ if (gitSeg) segs.push(gitSeg);
245
+ segs.push(ctxSeg, tokSeg);
246
+
247
+ // extension/package statuses (e.g. MCP servers) — appended as a final segment on the same line
248
+ const statuses = footerData.getExtensionStatuses();
249
+ if (statuses.size > 0) {
250
+ const statusLine = Array.from(statuses.entries())
251
+ .sort(([a], [b]) => a.localeCompare(b))
252
+ .map(([, t]) => (t ?? "").replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim())
253
+ .join(" ");
254
+ if (statusLine) {
255
+ segs.push(statusLine);
256
+ }
257
+ }
258
+
259
+ let line = segs.join(SEG);
260
+ if (visibleWidth(line) > width) {
261
+ line = truncateToWidth(line, width, "");
262
+ }
263
+ return [line];
264
+ },
265
+ };
266
+ });
267
+ }
268
+
269
+ pi.on("session_start", (_event, ctx) => {
270
+ // Re-install each session with a fresh ctx so model/sessionManager stay current.
271
+ if (ctx.mode !== "tui" || !userEnabled) return;
272
+ installFooter(ctx);
273
+ });
274
+
275
+ pi.registerCommand("cometix-footer", {
276
+ description: "Toggle cometix-style footer",
277
+ handler: async (_args, ctx) => {
278
+ if (ctx.mode !== "tui") return;
279
+ userEnabled = !userEnabled;
280
+ if (userEnabled) {
281
+ installFooter(ctx);
282
+ ctx.ui.notify("Cometix footer on", "info");
283
+ } else {
284
+ ctx.ui.setFooter(undefined);
285
+ if (timer) clearInterval(timer);
286
+ timer = undefined;
287
+ unsubBranch?.();
288
+ unsubBranch = undefined;
289
+ ctx.ui.notify("Cometix footer off (default restored)", "info");
290
+ }
291
+ },
292
+ });
293
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "pi-cometix-footer",
3
+ "version": "1.0.0",
4
+ "description": "Cometix-style single-line footer for pi. Borrows the cometix theme look from CCometixLine (MIT). Shows Model(+thinking) | Directory | Git(branch+status) | Context% tokens/window | Tokens(in/out + cache hit rate).",
5
+ "keywords": ["pi-package"],
6
+ "license": "MIT",
7
+ "author": "Xichun123",
8
+ "homepage": "https://github.com/Xichun123/pi-cometix-footer#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/Xichun123/pi-cometix-footer.git"
12
+ },
13
+ "files": ["extensions", "README.md"],
14
+ "pi": {
15
+ "extensions": ["./extensions"]
16
+ },
17
+ "peerDependencies": {
18
+ "@earendil-works/pi-coding-agent": "*",
19
+ "@earendil-works/pi-tui": "*"
20
+ }
21
+ }