@firstpick/pi-extension-git-footer-status 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/index.ts +531 -0
  4. package/package.json +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Firstpick
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,35 @@
1
+ # pi-extension-git-footer-status
2
+
3
+ Enhanced Pi footer with git health and model/token telemetry.
4
+
5
+ ## What it does
6
+
7
+ - Shows compact runtime metrics in the footer:
8
+ - input/output/cache tokens
9
+ - token speed estimate (`tok/s`)
10
+ - cost + context-window usage
11
+ - current model and reasoning level
12
+ - Shows git status context on the path line:
13
+ - branch/detached state
14
+ - ahead/behind
15
+ - staged/unstaged/untracked/conflicts
16
+ - operation state (rebase/merge/cherry-pick/revert/bisect)
17
+ - stash/submodule/worktree/tag/last-commit-age/signing mismatch indicators
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pi install npm:@firstpick/pi-extension-git-footer-status
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ No required configuration.
28
+
29
+ ## Commands
30
+
31
+ - `/git-footer-refresh` — refresh git/footer information immediately.
32
+
33
+ ## Tools
34
+
35
+ None.
package/index.ts ADDED
@@ -0,0 +1,531 @@
1
+ import { access } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
5
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+
8
+ type GitSnapshot = {
9
+ branch: string;
10
+ isDetached: boolean;
11
+ ahead: number;
12
+ behind: number;
13
+ staged: number;
14
+ unstaged: number;
15
+ untracked: number;
16
+ conflicted: number;
17
+ operation?: string;
18
+ stashCount: number;
19
+ submoduleDirty: number;
20
+ lastCommitAge?: string;
21
+ worktreeCount: number;
22
+ headTag?: string;
23
+ signingMismatch: boolean;
24
+ };
25
+
26
+ // Toggle footer items on/off here.
27
+ const FOOTER_FLAGS = {
28
+ branch: false,
29
+ detachedIndicator: true,
30
+ operationState: true,
31
+
32
+ ahead: true,
33
+ behind: true,
34
+
35
+ staged: true,
36
+ unstaged: true,
37
+ untracked: true,
38
+ conflicted: true,
39
+ clean: true,
40
+
41
+ stash: true,
42
+ submodules: true,
43
+ worktrees: true,
44
+ tag: true,
45
+ lastCommitAge: true,
46
+ signingMismatch: true,
47
+ } as const;
48
+
49
+ function formatCwd(cwd: string): string {
50
+ const home = homedir();
51
+ if (cwd === home) return "~";
52
+ if (cwd.startsWith(`${home}/`)) return `~/${cwd.slice(home.length + 1)}`;
53
+ return cwd;
54
+ }
55
+
56
+ function formatTokens(count: number): string {
57
+ if (count < 1000) return count.toString();
58
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
59
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
60
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
61
+ return `${Math.round(count / 1000000)}M`;
62
+ }
63
+
64
+ function normalizeTimestampMs(timestamp: number): number {
65
+ // Handle mixed timestamp units from different session formats.
66
+ // seconds -> ms (e.g. 1715000000)
67
+ // ms -> ms (e.g. 1715000000000)
68
+ // microsec -> ms (e.g. 1715000000000000)
69
+ if (timestamp < 1e11) return timestamp * 1000;
70
+ if (timestamp > 1e14) return Math.floor(timestamp / 1000);
71
+ return timestamp;
72
+ }
73
+
74
+ function getEntryTimestampMs(entry: { type: string; timestamp: string; message?: { timestamp?: number } }): number | null {
75
+ if (entry.type === "message" && typeof entry.message?.timestamp === "number") {
76
+ return normalizeTimestampMs(entry.message.timestamp);
77
+ }
78
+ const parsed = Date.parse(entry.timestamp);
79
+ return Number.isFinite(parsed) ? parsed : null;
80
+ }
81
+
82
+ function formatTokenSpeed(tokensPerSecond: number): string {
83
+ if (tokensPerSecond < 100) {
84
+ if (tokensPerSecond >= 10) return tokensPerSecond.toFixed(1);
85
+ return tokensPerSecond.toFixed(2);
86
+ }
87
+ if (tokensPerSecond < 1000) return Math.round(tokensPerSecond).toString();
88
+ if (tokensPerSecond < 10000) return `${(tokensPerSecond / 1000).toFixed(1)}k`;
89
+ if (tokensPerSecond < 1000000) return `${Math.round(tokensPerSecond / 1000)}k`;
90
+ if (tokensPerSecond < 10000000) return `${(tokensPerSecond / 1000000).toFixed(1)}M`;
91
+ return `${Math.round(tokensPerSecond / 1000000)}M`;
92
+ }
93
+
94
+ async function runGit(pi: ExtensionAPI, cwd: string, args: string[], timeout = 2000): Promise<string | undefined> {
95
+ const result = await pi.exec("git", args, { cwd, timeout }).catch(() => undefined);
96
+ if (!result || result.code !== 0) return undefined;
97
+ return result.stdout.trim();
98
+ }
99
+
100
+ async function pathExists(path: string): Promise<boolean> {
101
+ try {
102
+ await access(path);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ function toAgeLabel(epochSeconds: number): string | undefined {
110
+ if (!Number.isFinite(epochSeconds) || epochSeconds <= 0) return undefined;
111
+
112
+ const deltaSeconds = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds);
113
+ if (deltaSeconds < 60) return "now";
114
+
115
+ const minutes = Math.floor(deltaSeconds / 60);
116
+ if (minutes < 60) return `${minutes}m`;
117
+
118
+ const hours = Math.floor(minutes / 60);
119
+ if (hours < 24) return `${hours}h`;
120
+
121
+ const days = Math.floor(hours / 24);
122
+ return `${days}d`;
123
+ }
124
+
125
+ async function detectGitOperation(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
126
+ const gitDirRaw = await runGit(pi, cwd, ["rev-parse", "--git-dir"]);
127
+ if (!gitDirRaw) return undefined;
128
+
129
+ const gitDir = isAbsolute(gitDirRaw) ? gitDirRaw : resolve(cwd, gitDirRaw);
130
+
131
+ if ((await pathExists(resolve(gitDir, "rebase-merge"))) || (await pathExists(resolve(gitDir, "rebase-apply")))) {
132
+ return "REBASING";
133
+ }
134
+ if (await pathExists(resolve(gitDir, "MERGE_HEAD"))) return "MERGING";
135
+ if (await pathExists(resolve(gitDir, "CHERRY_PICK_HEAD"))) return "CHERRY-PICK";
136
+ if (await pathExists(resolve(gitDir, "REVERT_HEAD"))) return "REVERTING";
137
+ if (await pathExists(resolve(gitDir, "BISECT_LOG"))) return "BISECT";
138
+
139
+ return undefined;
140
+ }
141
+
142
+ async function readGitSnapshot(pi: ExtensionAPI, cwd: string): Promise<GitSnapshot | null> {
143
+ const result = await pi
144
+ .exec("git", ["status", "--porcelain=2", "--branch"], { cwd, timeout: 3000 })
145
+ .catch(() => undefined);
146
+
147
+ if (!result || result.code !== 0) {
148
+ return null;
149
+ }
150
+
151
+ let branch = "";
152
+ let detachedOid: string | undefined;
153
+ let ahead = 0;
154
+ let behind = 0;
155
+ let staged = 0;
156
+ let unstaged = 0;
157
+ let untracked = 0;
158
+ let conflicted = 0;
159
+
160
+ for (const line of result.stdout.split(/\r?\n/)) {
161
+ if (!line) continue;
162
+
163
+ if (line.startsWith("# branch.head ")) {
164
+ branch = line.slice("# branch.head ".length).trim();
165
+ continue;
166
+ }
167
+
168
+ if (line.startsWith("# branch.oid ")) {
169
+ const oid = line.slice("# branch.oid ".length).trim();
170
+ if (oid && oid !== "(initial)") detachedOid = oid;
171
+ continue;
172
+ }
173
+
174
+ if (line.startsWith("# branch.ab ")) {
175
+ const match = line.match(/\+(\d+)\s+-(\d+)/);
176
+ if (match) {
177
+ ahead = Number.parseInt(match[1] ?? "0", 10) || 0;
178
+ behind = Number.parseInt(match[2] ?? "0", 10) || 0;
179
+ }
180
+ continue;
181
+ }
182
+
183
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
184
+ const xy = line.split(" ")[1] ?? "..";
185
+ const x = xy[0] ?? ".";
186
+ const y = xy[1] ?? ".";
187
+ if (x !== ".") staged++;
188
+ if (y !== ".") unstaged++;
189
+ continue;
190
+ }
191
+
192
+ if (line.startsWith("u ")) {
193
+ conflicted++;
194
+ continue;
195
+ }
196
+
197
+ if (line.startsWith("? ")) {
198
+ untracked++;
199
+ continue;
200
+ }
201
+ }
202
+
203
+ const isDetached = !branch || branch === "(detached)";
204
+ const resolvedBranch =
205
+ !isDetached
206
+ ? branch
207
+ : detachedOid
208
+ ? `detached@${detachedOid.slice(0, 7)}`
209
+ : "detached";
210
+
211
+ const [operation, stashList, submoduleStatus, lastCommitTs, worktreeList, headTags, commitSignRequiredRaw, headSignState] =
212
+ await Promise.all([
213
+ detectGitOperation(pi, cwd),
214
+ runGit(pi, cwd, ["stash", "list", "--format=%gd"]),
215
+ runGit(pi, cwd, ["submodule", "status", "--recursive"]),
216
+ runGit(pi, cwd, ["log", "-1", "--format=%ct"]),
217
+ runGit(pi, cwd, ["worktree", "list", "--porcelain"]),
218
+ runGit(pi, cwd, ["tag", "--points-at", "HEAD", "--sort=-creatordate"]),
219
+ runGit(pi, cwd, ["config", "--bool", "--get", "commit.gpgsign"]),
220
+ runGit(pi, cwd, ["log", "-1", "--format=%G?"]),
221
+ ]);
222
+
223
+ const stashCount = stashList ? stashList.split(/\r?\n/).filter(Boolean).length : 0;
224
+
225
+ const submoduleDirty = submoduleStatus
226
+ ? submoduleStatus
227
+ .split(/\r?\n/)
228
+ .filter((line) => line && !line.startsWith(" "))
229
+ .length
230
+ : 0;
231
+
232
+ const worktreeCount = worktreeList
233
+ ? Math.max(
234
+ 1,
235
+ worktreeList
236
+ .split(/\r?\n/)
237
+ .filter((line) => line.startsWith("worktree ")).length,
238
+ )
239
+ : 1;
240
+
241
+ const headTag = headTags?.split(/\r?\n/).find(Boolean);
242
+
243
+ const lastCommitAge = lastCommitTs ? toAgeLabel(Number.parseInt(lastCommitTs, 10)) : undefined;
244
+
245
+ const commitSignRequired = commitSignRequiredRaw?.toLowerCase() === "true";
246
+ const signState = headSignState?.trim().toUpperCase();
247
+ const signingMismatch =
248
+ commitSignRequired &&
249
+ (!signState || signState === "N" || signState === "E");
250
+
251
+ return {
252
+ branch: resolvedBranch,
253
+ isDetached,
254
+ ahead,
255
+ behind,
256
+ staged,
257
+ unstaged,
258
+ untracked,
259
+ conflicted,
260
+ operation,
261
+ stashCount,
262
+ submoduleDirty,
263
+ lastCommitAge,
264
+ worktreeCount,
265
+ headTag,
266
+ signingMismatch,
267
+ };
268
+ }
269
+
270
+ function buildStatusText(ctx: ExtensionContext, snapshot: GitSnapshot): string {
271
+ const t = ctx.ui.theme;
272
+ const f = FOOTER_FLAGS;
273
+
274
+ const sectionSep = t.fg("dim", "│");
275
+ const itemSep = t.fg("dim", "·");
276
+
277
+ const branchSection: string[] = [];
278
+ if (f.branch) {
279
+ branchSection.push(t.fg("accent", ""), t.fg("accent", snapshot.branch));
280
+ }
281
+ if (f.detachedIndicator && snapshot.isDetached) branchSection.push(t.fg("warning", "⎇"));
282
+ if (f.operationState && snapshot.operation) branchSection.push(t.fg("warning", snapshot.operation));
283
+
284
+ const syncSection: string[] = [];
285
+ if (f.ahead && snapshot.ahead > 0) syncSection.push(t.fg("muted", `⇡${snapshot.ahead}`));
286
+ if (f.behind && snapshot.behind > 0) syncSection.push(t.fg("muted", `⇣${snapshot.behind}`));
287
+
288
+ const changesSection: string[] = [];
289
+ if (f.staged && snapshot.staged > 0) changesSection.push(t.fg("success", `+${snapshot.staged}`));
290
+ if (f.unstaged && snapshot.unstaged > 0) changesSection.push(t.fg("warning", `✎${snapshot.unstaged}`));
291
+ if (f.untracked && snapshot.untracked > 0) changesSection.push(t.fg("info", `◌${snapshot.untracked}`));
292
+ if (f.conflicted && snapshot.conflicted > 0) changesSection.push(t.fg("error", `!${snapshot.conflicted}`));
293
+
294
+ const extraSection: string[] = [];
295
+ if (f.stash && snapshot.stashCount > 0) extraSection.push(t.fg("muted", `⚑${snapshot.stashCount}`));
296
+ if (f.submodules && snapshot.submoduleDirty > 0) extraSection.push(t.fg("warning", `✖${snapshot.submoduleDirty}`));
297
+ if (f.worktrees && snapshot.worktreeCount > 1) extraSection.push(t.fg("muted", `📦${snapshot.worktreeCount}`));
298
+ if (f.tag && snapshot.headTag) extraSection.push(t.fg("accent", `🏷${snapshot.headTag}`));
299
+ if (f.lastCommitAge && snapshot.lastCommitAge) extraSection.push(t.fg("dim", `⏱${snapshot.lastCommitAge}`));
300
+ if (f.signingMismatch && snapshot.signingMismatch) extraSection.push(t.fg("warning", "🔒!"));
301
+
302
+ const isWorkingTreeClean =
303
+ snapshot.ahead === 0 &&
304
+ snapshot.behind === 0 &&
305
+ snapshot.staged === 0 &&
306
+ snapshot.unstaged === 0 &&
307
+ snapshot.untracked === 0 &&
308
+ snapshot.conflicted === 0;
309
+
310
+ if (f.clean && isWorkingTreeClean) {
311
+ changesSection.push(t.fg("dim", "clean"));
312
+ }
313
+
314
+ const sections = [branchSection, syncSection, changesSection, extraSection].filter(
315
+ (section) => section.length > 0,
316
+ );
317
+
318
+ return sections.length > 0
319
+ ? sections.map((section) => section.join(` ${itemSep} `)).join(` ${sectionSep} `)
320
+ : t.fg("dim", "git");
321
+ }
322
+
323
+ export default function gitFooterStatus(pi: ExtensionAPI) {
324
+ let refreshing = false;
325
+
326
+ const refresh = async (ctx: ExtensionContext) => {
327
+ if (refreshing) return;
328
+ refreshing = true;
329
+
330
+ try {
331
+ const snapshot = await readGitSnapshot(pi, ctx.cwd);
332
+ if (!snapshot) {
333
+ ctx.ui.setStatus("git-footer", undefined);
334
+ return;
335
+ }
336
+
337
+ ctx.ui.setStatus("git-footer", buildStatusText(ctx, snapshot));
338
+ } finally {
339
+ refreshing = false;
340
+ }
341
+ };
342
+
343
+ pi.on("session_start", async (_event, ctx) => {
344
+ ctx.ui.setFooter((tui, theme, footerData) => {
345
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
346
+
347
+ return {
348
+ dispose: unsub,
349
+ invalidate() {},
350
+ render(width: number): string[] {
351
+ let totalInput = 0;
352
+ let totalOutput = 0;
353
+ let totalCacheRead = 0;
354
+ let totalCacheWrite = 0;
355
+ let totalCost = 0;
356
+ let latestTokenSpeed: number | null = null;
357
+
358
+ const entries = ctx.sessionManager.getEntries();
359
+ for (let i = 0; i < entries.length; i++) {
360
+ const entry = entries[i];
361
+ if (entry.type === "message" && entry.message.role === "assistant") {
362
+ const message = entry.message as AssistantMessage;
363
+ totalInput += message.usage?.input ?? 0;
364
+ totalOutput += message.usage?.output ?? 0;
365
+ totalCacheRead += message.usage?.cacheRead ?? 0;
366
+ totalCacheWrite += message.usage?.cacheWrite ?? 0;
367
+ totalCost += message.usage?.cost?.total ?? 0;
368
+
369
+ if ((message.usage?.output ?? 0) > 0) {
370
+ const endMs = getEntryTimestampMs(entry);
371
+ if (endMs !== null) {
372
+ let fallbackSpeed: number | null = null;
373
+
374
+ for (let j = i - 1; j >= 0; j--) {
375
+ const previous = entries[j];
376
+ if (previous.type !== "message") continue;
377
+
378
+ // Skip assistant-to-assistant deltas (too noisy for speed).
379
+ if (previous.message.role === "assistant") continue;
380
+
381
+ const startMs = getEntryTimestampMs(previous);
382
+ if (startMs === null || endMs <= startMs) continue;
383
+
384
+ const elapsedSeconds = (endMs - startMs) / 1000;
385
+ if (elapsedSeconds <= 0) continue;
386
+
387
+ const speed = (message.usage?.output ?? 0) / elapsedSeconds;
388
+
389
+ // Prefer user-anchored speed (best approximation of full turn latency).
390
+ if (previous.message.role === "user") {
391
+ latestTokenSpeed = speed;
392
+ break;
393
+ }
394
+
395
+ // Keep first non-assistant speed as fallback if no user message is found.
396
+ if (fallbackSpeed === null) fallbackSpeed = speed;
397
+ }
398
+
399
+ if (latestTokenSpeed === null && fallbackSpeed !== null) {
400
+ latestTokenSpeed = fallbackSpeed;
401
+ }
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ const contextUsage = ctx.getContextUsage();
408
+ const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
409
+ const contextPercentValue = contextUsage?.percent ?? 0;
410
+ const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
411
+ const contextPercentDisplay =
412
+ contextPercent === "?" ? `?/${formatTokens(contextWindow)}` : `${contextPercent}%/${formatTokens(contextWindow)}`;
413
+
414
+ let contextPercentStr: string;
415
+ if (contextPercent === "?") {
416
+ contextPercentStr = theme.fg("dim", contextPercentDisplay);
417
+ } else if (contextPercentValue < 50) {
418
+ contextPercentStr = theme.fg("success", contextPercentDisplay);
419
+ } else if (contextPercentValue < 65) {
420
+ contextPercentStr = theme.fg("accent", contextPercentDisplay);
421
+ } else if (contextPercentValue < 75) {
422
+ contextPercentStr = theme.fg("muted", contextPercentDisplay);
423
+ } else if (contextPercentValue < 85) {
424
+ contextPercentStr = theme.fg("warning", contextPercentDisplay);
425
+ } else {
426
+ contextPercentStr = theme.fg("error", contextPercentDisplay);
427
+ }
428
+
429
+ const sectionSep = theme.fg("dim", "│");
430
+ const itemSep = theme.fg("dim", "·");
431
+
432
+ const ioItems: string[] = [];
433
+ if (totalInput) ioItems.push(`↑${formatTokens(totalInput)}`);
434
+ if (totalOutput) ioItems.push(`↓${formatTokens(totalOutput)}`);
435
+
436
+ const cacheItems: string[] = [];
437
+ if (totalCacheRead) cacheItems.push(`R${formatTokens(totalCacheRead)}`);
438
+ if (totalCacheWrite) cacheItems.push(`W${formatTokens(totalCacheWrite)}`);
439
+
440
+ const segments: string[] = [];
441
+ if (ioItems.length > 0) segments.push(`${theme.fg("muted", "🪙")} ${ioItems.join(` ${itemSep} `)}`);
442
+ if (cacheItems.length > 0) segments.push(`${theme.fg("muted", "💾")} ${cacheItems.join(` ${itemSep} `)}`);
443
+ if (latestTokenSpeed !== null) segments.push(`⚡ ${formatTokenSpeed(latestTokenSpeed)} tok/s`);
444
+
445
+ const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
446
+ if (totalCost || usingSubscription) {
447
+ segments.push(`${theme.fg("muted", "💸")} $${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
448
+ }
449
+
450
+ segments.push(`${theme.fg("muted", "🧠")} ${contextPercentStr}`);
451
+
452
+ let statsLeft = segments.join(` ${sectionSep} `);
453
+ let statsLeftWidth = visibleWidth(statsLeft);
454
+ if (statsLeftWidth > width) {
455
+ statsLeft = truncateToWidth(statsLeft, width, "...");
456
+ statsLeftWidth = visibleWidth(statsLeft);
457
+ }
458
+
459
+ const modelName = ctx.model?.id || "no-model";
460
+ const thinkingLevel = pi.getThinkingLevel();
461
+ const rightSideWithoutProvider =
462
+ ctx.model?.reasoning
463
+ ? thinkingLevel === "off"
464
+ ? `${modelName} • thinking off`
465
+ : `${modelName} • ${thinkingLevel}`
466
+ : modelName;
467
+
468
+ let rightSide = rightSideWithoutProvider;
469
+ if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
470
+ const withProvider = `(${ctx.model.provider}) ${rightSideWithoutProvider}`;
471
+ if (statsLeftWidth + 2 + visibleWidth(withProvider) <= width) {
472
+ rightSide = withProvider;
473
+ }
474
+ }
475
+
476
+ const rightSideWidth = visibleWidth(rightSide);
477
+ const totalNeeded = statsLeftWidth + 2 + rightSideWidth;
478
+ let tokenLine: string;
479
+
480
+ if (totalNeeded <= width) {
481
+ const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
482
+ tokenLine = statsLeft + padding + rightSide;
483
+ } else {
484
+ const availableForRight = width - statsLeftWidth - 2;
485
+ if (availableForRight > 0) {
486
+ const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
487
+ const truncatedRightWidth = visibleWidth(truncatedRight);
488
+ const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));
489
+ tokenLine = statsLeft + padding + truncatedRight;
490
+ } else {
491
+ tokenLine = statsLeft;
492
+ }
493
+ }
494
+
495
+ const branch = footerData.getGitBranch();
496
+ const cwdWithBranch = `${formatCwd(ctx.cwd)}${branch ? ` (${branch})` : ""}`;
497
+ const cwdText = theme.fg("muted", cwdWithBranch);
498
+ const status = footerData.getExtensionStatuses().get("git-footer");
499
+ const pathGitLine = status ? `${cwdText}${theme.fg("dim", " │ ")}${status}` : cwdText;
500
+
501
+ // Keep default subtle-grey look even when parts contain their own ANSI colors.
502
+ // Wrapping the whole line once is not enough because inner color resets cancel outer dim.
503
+ const dimStatsLeft = theme.fg("dim", statsLeft);
504
+ const remainder = tokenLine.slice(statsLeft.length);
505
+ const dimRemainder = theme.fg("dim", remainder);
506
+
507
+ return [truncateToWidth(dimStatsLeft + dimRemainder, width), truncateToWidth(pathGitLine, width)];
508
+ },
509
+ };
510
+ });
511
+
512
+ await refresh(ctx);
513
+ });
514
+
515
+ pi.on("turn_end", async (_event, ctx) => {
516
+ await refresh(ctx);
517
+ });
518
+
519
+ pi.on("session_shutdown", async (_event, ctx) => {
520
+ ctx.ui.setStatus("git-footer", undefined);
521
+ ctx.ui.setFooter(undefined);
522
+ });
523
+
524
+ pi.registerCommand("git-footer-refresh", {
525
+ description: "Refresh git footer information",
526
+ handler: async (_args, ctx) => {
527
+ await refresh(ctx);
528
+ ctx.ui.notify("Git footer refreshed", "info");
529
+ },
530
+ });
531
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@firstpick/pi-extension-git-footer-status",
3
+ "version": "0.1.0",
4
+ "description": "Enhanced Pi footer with git status, token usage, context usage, and model telemetry.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-coding-agent",
10
+ "extension"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./index.ts"
15
+ ]
16
+ },
17
+ "peerDependencies": {
18
+ "@mariozechner/pi-coding-agent": "*",
19
+ "@mariozechner/pi-ai": "*",
20
+ "@mariozechner/pi-tui": "*"
21
+ },
22
+ "files": [
23
+ "index.ts",
24
+ "README.md",
25
+ "LICENSE"
26
+ ]
27
+ }