pi-agent-extensions 0.3.6 → 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
@@ -34,6 +34,8 @@ Original repository: https://github.com/mitsuhiko/agent-stuff
34
34
  | **cwd_history** | Tracker | Tracks directory changes in context | ✅ Stable |
35
35
  | **btw** | Command | Quick side questions without history | ✅ Stable |
36
36
  | **nvidia-nim** | Command | Nvidia NIM auth & config | ✅ Stable |
37
+ | **powerline-footer** | UI | Custom powerline-style footer bar | ✅ Stable |
38
+ | **session-breakdown** | Command | Session analytics dashboard | ✅ Stable |
37
39
 
38
40
  ## Install
39
41
 
@@ -338,6 +340,23 @@ Authenticate and configure Nvidia NIM as an LLM provider.
338
340
  - Adds configured models to `~/.pi/agent/settings.json` `enabledModels` for scoped `/model` + Ctrl+P cycling
339
341
  - Model IDs must be `org/model` (exactly one `/`), e.g. `moonshotai/kimi-k2.5` (not `nvidia/moonshotai/kimi-k2.5`)
340
342
 
343
+ **Powerline Footer**
344
+ Custom powerline-style footer replacing the default pi footer with richer information.
345
+ - Git branch + working tree status (staged/unstaged/untracked/ahead/behind)
346
+ - Model name + context usage with color-coded percentage
347
+ - Estimated session cost from model pricing
348
+ - Session duration timer
349
+ - Python virtualenv / conda environment detection
350
+ - Extension statuses + session name
351
+ - Auto-refreshes every 10 seconds via async git commands
352
+
353
+ **Session Breakdown (`/session-breakdown`)**
354
+ Interactive analytics dashboard for your pi sessions.
355
+ - Analyzes all sessions in `~/.pi/agent/sessions/`
356
+ - Shows sessions/day, messages/day, tokens/day, cost/day
357
+ - Model breakdown with per-model usage stats
358
+ - Filterable by 7/30/90 day windows
359
+
341
360
  ## Development
342
361
 
343
362
  ```bash
@@ -189,11 +189,11 @@ function getSocketPath(sessionId: string): string {
189
189
  }
190
190
 
191
191
  function isSafeSessionId(sessionId: string): boolean {
192
- return !sessionId.includes("/") && !sessionId.includes("\\") && !sessionId.includes("..") && sessionId.length > 0;
192
+ return /^[a-zA-Z0-9_-]+$/.test(sessionId);
193
193
  }
194
194
 
195
195
  function isSafeAlias(alias: string): boolean {
196
- return !alias.includes("/") && !alias.includes("\\") && !alias.includes("..") && alias.length > 0;
196
+ return /^[a-zA-Z0-9_ -]+$/.test(alias);
197
197
  }
198
198
 
199
199
  function getAliasPath(alias: string): string {
@@ -15,8 +15,12 @@ import { Markdown, type MarkdownTheme } from "@mariozechner/pi-tui";
15
15
  * Send a desktop notification via OSC 777 escape sequence.
16
16
  */
17
17
  const notify = (title: string, body: string): void => {
18
+ // Sanitize to prevent terminal escape sequence injection (remove control characters like ESC and BEL)
19
+ const safeTitle = title.replace(/[\x00-\x1f\x7f]/g, "");
20
+ const safeBody = body.replace(/[\x00-\x1f\x7f]/g, "");
21
+
18
22
  // OSC 777 format: ESC ] 777 ; notify ; title ; body BEL
19
- process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
23
+ process.stdout.write(`\x1b]777;notify;${safeTitle};${safeBody}\x07`);
20
24
  };
21
25
 
22
26
  const isTextPart = (part: unknown): part is { type: "text"; text: string } =>
@@ -0,0 +1,220 @@
1
+ import type { ExtensionAPI, ExtensionContext, ContextUsage, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
2
+ import { type Component, type Theme, type TUI, visibleWidth } from "@mariozechner/pi-tui";
3
+ import * as child_process from "child_process";
4
+
5
+ class PowerlineFooter implements Component {
6
+ private tui: TUI;
7
+ private theme: Theme;
8
+ private footerData: ReadonlyFooterDataProvider;
9
+ private ctx: ExtensionContext;
10
+ private interval?: ReturnType<typeof setInterval>;
11
+ private sessionStartTime: number;
12
+
13
+ // Cached git state (updated async every 10s)
14
+ private gitStatusExtras: string = "";
15
+
16
+ constructor(tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider, ctx: ExtensionContext) {
17
+ this.tui = tui;
18
+ this.theme = theme;
19
+ this.footerData = footerData;
20
+ this.ctx = ctx;
21
+ this.sessionStartTime = Date.now();
22
+
23
+ this.fetchAsyncData();
24
+
25
+ this.interval = setInterval(() => {
26
+ this.fetchAsyncData();
27
+ this.tui.requestRender();
28
+ }, 10000);
29
+ }
30
+
31
+ dispose() {
32
+ if (this.interval) {
33
+ clearInterval(this.interval);
34
+ }
35
+ }
36
+
37
+ private fetchAsyncData() {
38
+ const cwd = this.ctx.cwd;
39
+ const branch = this.footerData.getGitBranch();
40
+
41
+ if (branch) {
42
+ child_process.exec("git --no-optional-locks status --porcelain", { encoding: "utf8", cwd }, (err, stdout) => {
43
+ if (err) return;
44
+ const staged = (stdout.match(/^[AMDRC]/gm) || []).length;
45
+ const unstaged = (stdout.match(/^.[MD]/gm) || []).length;
46
+ const untracked = (stdout.match(/^\?\?/gm) || []).length;
47
+
48
+ child_process.exec("git --no-optional-locks rev-list --count --left-right @{u}...HEAD", { encoding: "utf8", cwd }, (err2, revListOut) => {
49
+ let ahead = 0;
50
+ let behind = 0;
51
+ if (!err2 && revListOut && revListOut.includes("\t")) {
52
+ const [b, a] = revListOut.trim().split("\t");
53
+ behind = parseInt(b, 10);
54
+ ahead = parseInt(a, 10);
55
+ }
56
+
57
+ let statusExtras = "";
58
+ if (staged > 0) statusExtras += `+${staged}`;
59
+ if (unstaged > 0) statusExtras += `!${unstaged}`;
60
+ if (untracked > 0) statusExtras += `?${untracked}`;
61
+ if (ahead > 0) statusExtras += `⇡${ahead}`;
62
+ if (behind > 0) statusExtras += `⇣${behind}`;
63
+ this.gitStatusExtras = statusExtras;
64
+ this.tui.requestRender();
65
+ });
66
+ });
67
+ } else {
68
+ this.gitStatusExtras = "";
69
+ }
70
+ }
71
+
72
+ invalidate() {}
73
+ handleInput(_data: string) {}
74
+
75
+ private formatTokens(num: number): string {
76
+ if (num >= 1_000_000) {
77
+ return (num / 1_000_000).toFixed(1) + "M";
78
+ }
79
+ if (num >= 1000) {
80
+ return Math.floor(num / 1000) + "K";
81
+ }
82
+ return String(num);
83
+ }
84
+
85
+ render(width: number): string[] {
86
+ const RESET = "\x1b[0m";
87
+ const DIM = "\x1b[2m";
88
+ const BOLD = "\x1b[1m";
89
+ const MAUVE = "\x1b[38;5;183m";
90
+ const BLUE = "\x1b[38;5;111m";
91
+ const GREEN = "\x1b[38;5;150m";
92
+ const YELLOW = "\x1b[38;5;222m";
93
+ const RED = "\x1b[38;5;211m";
94
+ const SKY = "\x1b[38;5;117m";
95
+ const PEACH = "\x1b[38;5;216m";
96
+ const OVERLAY2 = "\x1b[38;5;103m";
97
+
98
+ // --- Directory ---
99
+ const cwd = this.ctx.cwd;
100
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
101
+ let shortDir = cwd;
102
+ if (homeDir && shortDir.startsWith(homeDir)) {
103
+ shortDir = "~" + shortDir.slice(homeDir.length);
104
+ }
105
+ const parts = shortDir.split("/");
106
+ if (parts.length > 4) {
107
+ shortDir = parts.slice(parts.length - 4).join("/");
108
+ }
109
+
110
+ // --- Git ---
111
+ let gitInfo = "";
112
+ const branch = this.footerData.getGitBranch();
113
+ if (branch) {
114
+ gitInfo = ` ${DIM}on${RESET} ${MAUVE} ${branch}${RESET}`;
115
+ if (this.gitStatusExtras) {
116
+ gitInfo += ` ${RED}${this.gitStatusExtras}${RESET}`;
117
+ }
118
+ }
119
+
120
+ // --- Model ---
121
+ const model = this.ctx.model;
122
+ const modelShort = model?.name || model?.id || "unknown";
123
+
124
+ // --- Context usage ---
125
+ const contextUsage = this.ctx.getContextUsage();
126
+ let contextInfo = "";
127
+ let costInfo = "";
128
+
129
+ if (contextUsage) {
130
+ const used = contextUsage.tokens;
131
+ const total = contextUsage.contextWindow;
132
+ const pct = contextUsage.percent;
133
+ const remaining = 100 - pct;
134
+
135
+ let ctxColor = GREEN;
136
+ if (remaining < 20) {
137
+ ctxColor = RED;
138
+ } else if (remaining < 50) {
139
+ ctxColor = YELLOW;
140
+ }
141
+
142
+ contextInfo = `${ctxColor}${this.formatTokens(used)}/${this.formatTokens(total)} (${pct}%)${RESET}`;
143
+
144
+ // Cost estimation from model pricing
145
+ if (model?.cost && contextUsage.usageTokens > 0) {
146
+ // Rough estimate: treat usageTokens as input, trailingTokens as recent output
147
+ const inputTokens = contextUsage.usageTokens;
148
+ const outputTokens = contextUsage.trailingTokens;
149
+ const cost =
150
+ (inputTokens * model.cost.input) / 1_000_000 +
151
+ (outputTokens * model.cost.output) / 1_000_000;
152
+
153
+ if (cost > 0.001) {
154
+ costInfo = ` ${DIM}|${RESET} ${PEACH}$${cost.toFixed(2)}${RESET}`;
155
+ }
156
+ }
157
+ }
158
+
159
+ // --- Session duration ---
160
+ let durationInfo = "";
161
+ const durationMs = Date.now() - this.sessionStartTime;
162
+ if (durationMs > 0) {
163
+ const durS = Math.floor(durationMs / 1000);
164
+ let durFmt = "";
165
+ if (durS >= 3600) {
166
+ const durH = Math.floor(durS / 3600);
167
+ const durM = Math.floor((durS % 3600) / 60);
168
+ durFmt = `${durH}h${durM}m`;
169
+ } else if (durS >= 60) {
170
+ durFmt = `${Math.floor(durS / 60)}m`;
171
+ } else {
172
+ durFmt = `${durS}s`;
173
+ }
174
+ durationInfo = ` ${DIM}|${RESET} ${OVERLAY2}${durFmt}${RESET}`;
175
+ }
176
+
177
+ // --- Python env ---
178
+ let envInfo = "";
179
+ if (process.env.CONDA_DEFAULT_ENV) {
180
+ envInfo = ` ${SKY}(${process.env.CONDA_DEFAULT_ENV})${RESET}`;
181
+ } else if (process.env.VIRTUAL_ENV) {
182
+ const venvName = process.env.VIRTUAL_ENV.split("/").pop() || "";
183
+ envInfo = ` ${SKY}(${venvName})${RESET}`;
184
+ }
185
+
186
+ // --- Time ---
187
+ const date = new Date();
188
+ const currentTime = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
189
+
190
+ // --- Session name ---
191
+ const sessionName = this.ctx.sessionManager.getSessionName();
192
+ const sessionInfo = sessionName ? `${MAUVE}[${sessionName}]${RESET} ` : "";
193
+
194
+ // --- Extension statuses ---
195
+ let statusInfo = "";
196
+ const statuses = this.footerData.getExtensionStatuses();
197
+ if (statuses.size > 0) {
198
+ const parts: string[] = [];
199
+ for (const [, text] of statuses) {
200
+ if (text) parts.push(text);
201
+ }
202
+ if (parts.length > 0) {
203
+ statusInfo = ` ${DIM}|${RESET} ${parts.join(" ")}`;
204
+ }
205
+ }
206
+
207
+ const line = `${sessionInfo}${BOLD}${BLUE} ${shortDir}${RESET}${gitInfo} ${DIM}|${RESET} ${OVERLAY2}${modelShort}${RESET} ${contextInfo}${costInfo}${durationInfo}${envInfo}${statusInfo} ${DIM}${currentTime}${RESET}`;
208
+
209
+ return [line];
210
+ }
211
+ }
212
+
213
+ export default function (pi: ExtensionAPI) {
214
+ pi.on("session_start", async (_event, ctx) => {
215
+ if (!ctx.hasUI) return;
216
+ ctx.ui.setFooter((tui, theme, footerData) => {
217
+ return new PowerlineFooter(tui, theme, footerData, ctx);
218
+ });
219
+ });
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-extensions",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Collection of extensions for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -47,7 +47,8 @@
47
47
  "./extensions/todos/index.ts",
48
48
  "./extensions/whimsical/index.ts",
49
49
  "./extensions/nvidia-nim/index.ts",
50
- "./extensions/btw/index.ts"
50
+ "./extensions/btw/index.ts",
51
+ "./extensions/powerline-footer/index.ts"
51
52
  ],
52
53
  "themes": [
53
54
  "./themes/nightowl.json",