Commit 706cf8bca555

Vincent Demeester <vincent@sbr.pm>
2026-02-06 16:16:08
feat(pi): add comprehensive custom footer with full session stats
Replaces hostname.ts with a feature-rich footer showing: - Time, hostname, abbreviated path, git branch - Git worktree indicator (๐ŸŒณ) when in a worktree - Provider/model (e.g., google-vertex-claude/sonnet-4.5) - Thinking indicator (๐Ÿง  ext) when enabled - Total R/W tokens and cost across session - Context usage percentage with auto-compact indicator - 30-second git caching for performance Handles @date suffixes in model IDs and uses actual provider from configuration instead of inference. Example footer: 16:10 ๐Ÿ–ฅ๏ธ kyushu ~/s/home main ๐ŸŒณ google-vertex-claude/sonnet-4.5 ๐Ÿง  ext R11M W313k $5.289 76.2%/200k (auto)
1 parent 40ca644
Changed files (2)
dots
dots/pi/agent/extensions/custom-footer.ts
@@ -0,0 +1,441 @@
+/**
+ * Custom Footer Extension
+ *
+ * Shows comprehensive status in the footer:
+ * - Hostname
+ * - Abbreviated folder path (~/s/home for ~/src/home)
+ * - Git branch (with dirty indicator)
+ * - Model name
+ * - Token stats (input/output/cost)
+ * - Current time
+ *
+ * Format: ๐Ÿ–ฅ๏ธ kyushu  ~/s/home  main  sonnet-4  โ†‘5.2k โ†“2.1k $0.15  16:10
+ */
+
+import type { AssistantMessage } from "@mariozechner/pi-ai";
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
+import { hostname } from "node:os";
+import { execSync } from "node:child_process";
+import path from "node:path";
+
+// =============================================================================
+// Path Abbreviation
+// =============================================================================
+
+/**
+ * Abbreviate path to ~/s/h format (first letter of each directory)
+ * ~/src/home -> ~/s/home
+ * ~/src/tektoncd/pipeline -> ~/s/t/pipeline
+ */
+function abbreviatePath(fullPath: string): string {
+	const home = process.env.HOME || "";
+	let displayPath = fullPath;
+
+	// Replace home with ~
+	if (home && fullPath.startsWith(home)) {
+		displayPath = "~" + fullPath.slice(home.length);
+	}
+
+	// Split path
+	const parts = displayPath.split(path.sep).filter(Boolean);
+	if (parts.length <= 2) {
+		// Short enough already (e.g., ~/home or ~/src)
+		return displayPath;
+	}
+
+	// Abbreviate middle directories, keep last full
+	const abbreviated = parts.map((part, i) => {
+		// Keep first part (~) and last part full
+		if (i === 0 || i === parts.length - 1) {
+			return part;
+		}
+		// Abbreviate middle parts to first letter
+		return part[0];
+	});
+
+	return abbreviated.join(path.sep);
+}
+
+// =============================================================================
+// Git Info
+// =============================================================================
+
+// Git info caching to avoid running git commands on every render
+// Without caching, git status would run potentially hundreds of times per second
+// during typing/rendering, causing performance issues and UI lag
+let cachedGitBranch: string | null = null;
+let cachedGitDirty: boolean = false;
+let cachedGitWorktree: boolean = false;
+let gitCacheTime = 0;
+const GIT_CACHE_MS = 30000; // Cache for 30 seconds - balance between freshness and performance
+
+function getGitInfo(): { branch: string; dirty: boolean; worktree: boolean } {
+	const now = Date.now();
+	if (now - gitCacheTime < GIT_CACHE_MS && cachedGitBranch !== null) {
+		return { branch: cachedGitBranch, dirty: cachedGitDirty, worktree: cachedGitWorktree };
+	}
+
+	try {
+		// Get branch
+		const branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", {
+			encoding: "utf-8",
+			timeout: 1000,
+		}).trim();
+
+		// Check if dirty (uncommitted changes)
+		const status = execSync("git status --porcelain 2>/dev/null", {
+			encoding: "utf-8",
+			timeout: 1000,
+		}).trim();
+
+		// Check if we're in a worktree (git-dir contains /worktrees/)
+		const gitDir = execSync("git rev-parse --git-dir 2>/dev/null", {
+			encoding: "utf-8",
+			timeout: 1000,
+		}).trim();
+
+		cachedGitBranch = branch || "";
+		cachedGitDirty = status.length > 0;
+		cachedGitWorktree = gitDir.includes("/worktrees/");
+		gitCacheTime = now;
+
+		return { branch: cachedGitBranch, dirty: cachedGitDirty, worktree: cachedGitWorktree };
+	} catch {
+		cachedGitBranch = "";
+		cachedGitDirty = false;
+		cachedGitWorktree = false;
+		gitCacheTime = now;
+		return { branch: "", dirty: false, worktree: false };
+	}
+}
+
+// =============================================================================
+// Model Name Shortening
+// =============================================================================
+
+function getShortModelName(modelId: string): string {
+	// Remove @date suffix if present (e.g., claude-sonnet-4-5@20250929 -> claude-sonnet-4-5)
+	const baseId = modelId.split("@")[0];
+	
+	const shortNames: Record<string, string> = {
+		"claude-sonnet-4-5": "sonnet-4.5",
+		"claude-sonnet-4-5-20250514": "sonnet-4.5",
+		"claude-sonnet-4": "sonnet-4",
+		"claude-sonnet-4-20250514": "sonnet-4",
+		"claude-3-5-sonnet-20241022": "sonnet-3.5",
+		"claude-3-5-sonnet": "sonnet-3.5",
+		"claude-sonnet": "sonnet",
+		"claude-opus-4-5": "opus-4.5",
+		"claude-opus-4": "opus-4",
+		"claude-opus-4-20250514": "opus-4",
+		"claude-3-opus": "opus-3",
+		"claude-opus": "opus",
+		"claude-haiku-4": "haiku-4",
+		"claude-haiku": "haiku",
+		"claude-3-haiku": "haiku-3",
+		"gpt-4o": "4o",
+		"gpt-4o-mini": "4o-mini",
+		"gpt-4-turbo": "4-turbo",
+		"gpt-4": "gpt4",
+		"gpt-3.5-turbo": "3.5",
+		"gemini-2.0-flash-exp": "flash-2-exp",
+		"gemini-2.0-flash": "flash-2",
+		"gemini-2.0-pro-exp": "pro-2-exp",
+		"gemini-2.0-pro": "pro-2",
+		"gemini-1.5-pro": "pro-1.5",
+		"gemini-1.5-flash": "flash-1.5",
+	};
+
+	// Try exact match first, then base ID without @date
+	return shortNames[modelId] || shortNames[baseId] || baseId;
+}
+
+// =============================================================================
+// Token Stats (Total Read/Written across session)
+// =============================================================================
+
+function formatNumber(n: number): string {
+	if (n < 1000) return `${n}`;
+	if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
+	if (n < 1000000) return `${Math.floor(n / 1000)}k`;
+	return `${(n / 1000000).toFixed(1)}M`;
+}
+
+function getTokenStats(ctx: any): { 
+	totalRead: number; 
+	totalWritten: number; 
+	cost: number 
+} {
+	let totalRead = 0;   // R = total input tokens (read by model)
+	let totalWritten = 0; // W = total output tokens (written by model)
+	let cost = 0;
+
+	for (const e of ctx.sessionManager.getBranch()) {
+		if (e.type === "message" && e.message.role === "assistant") {
+			const m = e.message as AssistantMessage;
+			totalRead += m.usage.input;
+			totalWritten += m.usage.output;
+			cost += m.usage.cost.total;
+		}
+	}
+
+	return { totalRead, totalWritten, cost };
+}
+
+// =============================================================================
+// Provider Info
+// =============================================================================
+
+function getProviderName(model: any): string {
+	// Use the actual provider from model config, not inferred from ID
+	return model?.provider || "unknown";
+}
+
+// =============================================================================
+// Thinking Level
+// =============================================================================
+
+function getThinkingInfo(pi: any): { emoji: string; level: string } | null {
+	// Get thinking level from pi API
+	const thinkingLevel = pi.getThinkingLevel?.();
+	
+	if (!thinkingLevel || thinkingLevel === "off") return null;
+	
+	// Map thinking levels to short display names
+	const levelMap: Record<string, string> = {
+		"minimal": "min",
+		"low": "low",
+		"medium": "med", 
+		"high": "high",
+		"xhigh": "ext",
+	};
+	
+	return {
+		emoji: "๐Ÿง ",
+		level: levelMap[thinkingLevel] || thinkingLevel,
+	};
+}
+
+// =============================================================================
+// Context Usage
+// =============================================================================
+
+function getContextInfo(ctx: any, autoCompact: boolean): string | null {
+	try {
+		const usage = ctx.getContextUsage?.();
+		if (!usage) return null;
+		
+		const percent = usage.limit > 0 
+			? ((usage.tokens / usage.limit) * 100).toFixed(1)
+			: "0";
+		
+		const compactStatus = autoCompact ? " (auto)" : "";
+		
+		return `${percent}%/${formatNumber(usage.limit)}${compactStatus}`;
+	} catch {
+		return null;
+	}
+}
+
+// =============================================================================
+// Time
+// =============================================================================
+
+function getCurrentTime(): string {
+	const now = new Date();
+	const hours = String(now.getHours()).padStart(2, "0");
+	const minutes = String(now.getMinutes()).padStart(2, "0");
+	return `${hours}:${minutes}`;
+}
+
+// =============================================================================
+// Extension
+// =============================================================================
+
+export default function (pi: ExtensionAPI) {
+	let timeUpdateInterval: NodeJS.Timeout | null = null;
+	let autoCompactEnabled = false;
+
+	// Track auto-compact status
+	pi.on("session_start", async (_event, ctx) => {
+		// Check if auto-compact is enabled from settings
+		autoCompactEnabled = ctx.sessionManager.getAutoCompact?.() || false;
+	});
+
+	pi.on("session_start", async (_event, ctx) => {
+		// Clear any existing interval
+		if (timeUpdateInterval) {
+			clearInterval(timeUpdateInterval);
+		}
+
+		ctx.ui.setFooter((tui, theme, footerData) => {
+			const unsub = footerData.onBranchChange(() => tui.requestRender());
+
+			// Update time every minute
+			timeUpdateInterval = setInterval(() => {
+				tui.requestRender();
+			}, 60000); // 60 seconds
+
+			return {
+				dispose: () => {
+					unsub();
+					if (timeUpdateInterval) {
+						clearInterval(timeUpdateInterval);
+						timeUpdateInterval = null;
+					}
+				},
+				invalidate() {},
+				render(width: number): string[] {
+					// Get all info
+					const time = getCurrentTime();
+					const host = hostname();
+					const folder = abbreviatePath(ctx.cwd);
+					const git = getGitInfo();
+					const modelId = ctx.model?.id || "no-model";
+					const provider = getProviderName(ctx.model);
+					const modelName = getShortModelName(modelId);
+					const stats = getTokenStats(ctx);
+					const thinking = getThinkingInfo(pi);
+					const contextInfo = getContextInfo(ctx, autoCompactEnabled);
+
+					// Build components
+					const timeText = theme.fg("dim", time);
+					const hostIcon = theme.fg("accent", "๐Ÿ–ฅ๏ธ");
+					const hostText = theme.fg("accent", host);
+					const folderText = theme.fg("dim", folder);
+					
+					const gitBranch = git.branch
+						? theme.fg("success", git.branch + (git.dirty ? "*" : "") + (git.worktree ? " ๐ŸŒณ" : ""))
+						: "";
+					
+					// Provider/Model format: google-vertex/sonnet-4.5
+					const providerModel = theme.fg("accent", `${provider}/${modelName}`);
+					
+					// Thinking indicator (only if enabled)
+					const thinkingText = thinking 
+						? theme.fg("accent", `${thinking.emoji} ${thinking.level}`)
+						: "";
+					
+					// Token stats: R11M W313k $5.289
+					const tokenStats = theme.fg(
+						"dim",
+						`R${formatNumber(stats.totalRead)} W${formatNumber(stats.totalWritten)} $${stats.cost.toFixed(3)}`
+					);
+					
+					// Context usage: 76.2%/200k (auto)
+					const contextText = contextInfo ? theme.fg("dim", contextInfo) : "";
+
+					// Combine components with separators
+					// Format: 16:10  ๐Ÿ–ฅ๏ธ kyushu  ~/s/home  main  google-vertex/sonnet-4.5  ๐Ÿง  ext  R11M W313k $5.289  76.2%/200k (auto)
+					const components = [
+						timeText,
+						`${hostIcon} ${hostText}`,
+						folderText,
+						gitBranch,
+						providerModel,
+						thinkingText,
+						tokenStats,
+						contextText,
+					].filter(Boolean); // Remove empty strings
+
+					const separator = theme.fg("dim", "  ");
+					const footer = components.join(separator);
+
+					return [truncateToWidth(footer, width)];
+				},
+			};
+		});
+	});
+
+	pi.on("session_switch", async (_event, ctx) => {
+		// Re-setup footer on session switch
+		if (timeUpdateInterval) {
+			clearInterval(timeUpdateInterval);
+		}
+
+		ctx.ui.setFooter((tui, theme, footerData) => {
+			const unsub = footerData.onBranchChange(() => tui.requestRender());
+
+			timeUpdateInterval = setInterval(() => {
+				tui.requestRender();
+			}, 60000);
+
+			return {
+				dispose: () => {
+					unsub();
+					if (timeUpdateInterval) {
+						clearInterval(timeUpdateInterval);
+						timeUpdateInterval = null;
+					}
+				},
+				invalidate() {},
+				render(width: number): string[] {
+					// Get all info
+					const time = getCurrentTime();
+					const host = hostname();
+					const folder = abbreviatePath(ctx.cwd);
+					const git = getGitInfo();
+					const modelId = ctx.model?.id || "no-model";
+					const provider = getProviderName(ctx.model);
+					const modelName = getShortModelName(modelId);
+					const stats = getTokenStats(ctx);
+					const thinking = getThinkingInfo(pi);
+					const contextInfo = getContextInfo(ctx, autoCompactEnabled);
+
+					// Build components
+					const timeText = theme.fg("dim", time);
+					const hostIcon = theme.fg("accent", "๐Ÿ–ฅ๏ธ");
+					const hostText = theme.fg("accent", host);
+					const folderText = theme.fg("dim", folder);
+					
+					const gitBranch = git.branch
+						? theme.fg("success", git.branch + (git.dirty ? "*" : "") + (git.worktree ? " ๐ŸŒณ" : ""))
+						: "";
+					
+					// Provider/Model format: google-vertex/sonnet-4.5
+					const providerModel = theme.fg("accent", `${provider}/${modelName}`);
+					
+					// Thinking indicator (only if enabled)
+					const thinkingText = thinking 
+						? theme.fg("accent", `${thinking.emoji} ${thinking.level}`)
+						: "";
+					
+					// Token stats: R11M W313k $5.289
+					const tokenStats = theme.fg(
+						"dim",
+						`R${formatNumber(stats.totalRead)} W${formatNumber(stats.totalWritten)} $${stats.cost.toFixed(3)}`
+					);
+					
+					// Context usage: 76.2%/200k (auto)
+					const contextText = contextInfo ? theme.fg("dim", contextInfo) : "";
+
+					// Combine components with separators
+					// Format: 16:10  ๐Ÿ–ฅ๏ธ kyushu  ~/s/home  main  google-vertex/sonnet-4.5  ๐Ÿง  ext  R11M W313k $5.289  76.2%/200k (auto)
+					const components = [
+						timeText,
+						`${hostIcon} ${hostText}`,
+						folderText,
+						gitBranch,
+						providerModel,
+						thinkingText,
+						tokenStats,
+						contextText,
+					].filter(Boolean);
+
+					const separator = theme.fg("dim", "  ");
+					const footer = components.join(separator);
+
+					return [truncateToWidth(footer, width)];
+				},
+			};
+		});
+	});
+
+	pi.on("session_shutdown", async () => {
+		if (timeUpdateInterval) {
+			clearInterval(timeUpdateInterval);
+			timeUpdateInterval = null;
+		}
+	});
+}
dots/pi/agent/extensions/hostname.ts
@@ -1,16 +0,0 @@
-/**
- * Hostname Extension
- *
- * Displays the current hostname in the status line (footer).
- * Useful when working across multiple machines.
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { hostname } from "node:os";
-
-export default function (pi: ExtensionAPI) {
-	pi.on("session_start", async (_event, ctx) => {
-		const host = hostname();
-		ctx.ui.setStatus("hostname", ctx.ui.theme.fg("accent", `๐Ÿ–ฅ๏ธ ${host}`));
-	});
-}