main
  1/**
  2 * Custom Footer Extension
  3 *
  4 * Shows comprehensive status in the footer:
  5 * - Hostname
  6 * - Abbreviated folder path (~/s/home for ~/src/home)
  7 * - Git branch (with dirty indicator)
  8 * - Model name
  9 * - Context usage
 10 * - Current time
 11 *
 12 * Format: ️ kyushu  ~/s/home  main  sonnet-4  76.2%/200k  16:10
 13 */
 14
 15import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
 16import { truncateToWidth } from "@earendil-works/pi-tui";
 17import { hostname } from "node:os";
 18import { execSync } from "node:child_process";
 19import path from "node:path";
 20
 21// =============================================================================
 22// Path Abbreviation
 23// =============================================================================
 24
 25/**
 26 * Abbreviate path to ~/s/h format (first letter of each directory)
 27 * Dotfiles preserve the dot prefix (.local → .l).
 28 * ~/src/home -> ~/s/home
 29 * ~/src/tektoncd/pipeline -> ~/s/t/pipeline
 30 * ~/.local/share/worktrees/foo -> ~/.l/s/w/foo
 31 */
 32function abbreviatePath(fullPath: string): string {
 33	const home = process.env.HOME || "";
 34	let displayPath = fullPath;
 35
 36	// Replace home with ~
 37	if (home && fullPath.startsWith(home)) {
 38		displayPath = "~" + fullPath.slice(home.length);
 39	}
 40
 41	// Split path
 42	const parts = displayPath.split(path.sep).filter(Boolean);
 43	if (parts.length <= 2) {
 44		// Short enough already (e.g., ~/home or ~/src)
 45		return displayPath;
 46	}
 47
 48	// Abbreviate middle directories, keep last full
 49	const abbreviated = parts.map((part, i) => {
 50		// Keep first part (~) and last part full
 51		if (i === 0 || i === parts.length - 1) {
 52			return part;
 53		}
 54		// Keep dot prefix for hidden dirs (.local → .l)
 55		if (part.startsWith(".")) {
 56			return "." + part[1];
 57		}
 58		// Abbreviate middle parts to first letter
 59		return part[0];
 60	});
 61
 62	return abbreviated.join(path.sep);
 63}
 64
 65// =============================================================================
 66// Git Info
 67// =============================================================================
 68
 69// Git info caching to avoid running git commands on every render
 70// Without caching, git status would run potentially hundreds of times per second
 71// during typing/rendering, causing performance issues and UI lag
 72let cachedGitBranch: string | null = null;
 73let cachedGitDirty: boolean = false;
 74let cachedGitWorktree: boolean = false;
 75let gitCacheTime = 0;
 76const GIT_CACHE_MS = 30000; // Cache for 30 seconds - balance between freshness and performance
 77
 78function getGitInfo(): { branch: string; dirty: boolean; worktree: boolean } {
 79	const now = Date.now();
 80	if (now - gitCacheTime < GIT_CACHE_MS && cachedGitBranch !== null) {
 81		return { branch: cachedGitBranch, dirty: cachedGitDirty, worktree: cachedGitWorktree };
 82	}
 83
 84	try {
 85		// Get branch
 86		const branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", {
 87			encoding: "utf-8",
 88			timeout: 1000,
 89		}).trim();
 90
 91		// Check if dirty (uncommitted changes)
 92		const status = execSync("git status --porcelain 2>/dev/null", {
 93			encoding: "utf-8",
 94			timeout: 1000,
 95		}).trim();
 96
 97		// Check if we're in a worktree (git-dir contains /worktrees/)
 98		const gitDir = execSync("git rev-parse --git-dir 2>/dev/null", {
 99			encoding: "utf-8",
100			timeout: 1000,
101		}).trim();
102
103		cachedGitBranch = branch || "";
104		cachedGitDirty = status.length > 0;
105		cachedGitWorktree = gitDir.includes("/worktrees/");
106		gitCacheTime = now;
107
108		return { branch: cachedGitBranch, dirty: cachedGitDirty, worktree: cachedGitWorktree };
109	} catch {
110		cachedGitBranch = "";
111		cachedGitDirty = false;
112		cachedGitWorktree = false;
113		gitCacheTime = now;
114		return { branch: "", dirty: false, worktree: false };
115	}
116}
117
118// =============================================================================
119// Model Name Shortening
120// =============================================================================
121
122function getShortModelName(modelId: string): string {
123	// Remove @date suffix if present (e.g., claude-sonnet-4-5@20250929 -> claude-sonnet-4-5)
124	const baseId = modelId.split("@")[0];
125	
126	const shortNames: Record<string, string> = {
127		"claude-sonnet-4-6": "sonnet-4.6",
128		"claude-sonnet-4-5": "sonnet-4.5",
129		"claude-sonnet-4-5-20250514": "sonnet-4.5",
130		"claude-sonnet-4": "sonnet-4",
131		"claude-sonnet-4-20250514": "sonnet-4",
132		"claude-3-5-sonnet-20241022": "sonnet-3.5",
133		"claude-3-5-sonnet": "sonnet-3.5",
134		"claude-sonnet": "sonnet",
135		"claude-opus-4-5": "opus-4.5",
136		"claude-opus-4": "opus-4",
137		"claude-opus-4-20250514": "opus-4",
138		"claude-3-opus": "opus-3",
139		"claude-opus": "opus",
140		"claude-haiku-4": "haiku-4",
141		"claude-haiku": "haiku",
142		"claude-3-haiku": "haiku-3",
143		"gpt-4o": "4o",
144		"gpt-4o-mini": "4o-mini",
145		"gpt-4-turbo": "4-turbo",
146		"gpt-4": "gpt4",
147		"gpt-3.5-turbo": "3.5",
148		"gemini-2.0-flash-exp": "flash-2-exp",
149		"gemini-2.0-flash": "flash-2",
150		"gemini-2.0-pro-exp": "pro-2-exp",
151		"gemini-2.0-pro": "pro-2",
152		"gemini-1.5-pro": "pro-1.5",
153		"gemini-1.5-flash": "flash-1.5",
154	};
155
156	// Try exact match first, then base ID without @date
157	return shortNames[modelId] || shortNames[baseId] || baseId;
158}
159
160// =============================================================================
161// Formatting Helpers
162// =============================================================================
163
164function formatNumber(n: number): string {
165	if (n < 1000) return `${n}`;
166	if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
167	if (n < 1000000) return `${Math.floor(n / 1000)}k`;
168	return `${(n / 1000000).toFixed(1)}M`;
169}
170
171// =============================================================================
172// Provider Info
173// =============================================================================
174
175function getProviderName(model: any): string {
176	// Use the actual provider from model config, not inferred from ID
177	return model?.provider || "unknown";
178}
179
180// =============================================================================
181// Thinking Level
182// =============================================================================
183
184function getThinkingInfo(pi: any): { emoji: string; level: string } | null {
185	// Get thinking level from pi API
186	const thinkingLevel = pi.getThinkingLevel?.();
187	
188	if (!thinkingLevel || thinkingLevel === "off") return null;
189	
190	// Map thinking levels to short display names
191	const levelMap: Record<string, string> = {
192		"minimal": "min",
193		"low": "low",
194		"medium": "med", 
195		"high": "high",
196		"xhigh": "ext",
197	};
198	
199	return {
200		emoji: "🧠",
201		level: levelMap[thinkingLevel] || thinkingLevel,
202	};
203}
204
205// =============================================================================
206// Mode Display
207// =============================================================================
208
209/** Convert hex color (#rrggbb) to 24-bit ANSI foreground escape sequence */
210function hexToAnsi(hex: string): string {
211	const h = hex.replace("#", "");
212	const r = parseInt(h.substring(0, 2), 16);
213	const g = parseInt(h.substring(2, 4), 16);
214	const b = parseInt(h.substring(4, 6), 16);
215	return `\x1b[38;2;${r};${g};${b}m`;
216}
217
218function getModeText(modeName: string, hexColor: string | undefined, theme: any): string {
219	const label = `[${modeName}]`;
220	if (hexColor && /^#[0-9a-fA-F]{6}$/.test(hexColor)) {
221		return `${hexToAnsi(hexColor)}${label}\x1b[39m`;
222	}
223	return theme.fg("dim", label);
224}
225
226// =============================================================================
227// Context Usage
228// =============================================================================
229
230function getContextInfo(ctx: any, autoCompact: boolean): string | null {
231	try {
232		const usage = ctx.getContextUsage?.();
233		if (!usage || !usage.contextWindow || usage.contextWindow === 0) return null;
234		
235		const percent = ((usage.tokens / usage.contextWindow) * 100).toFixed(1);
236		const compactStatus = autoCompact ? " (auto)" : "";
237		
238		return `${percent}%/${formatNumber(usage.contextWindow)}${compactStatus}`;
239	} catch {
240		return null;
241	}
242}
243
244// =============================================================================
245// Time
246// =============================================================================
247
248function getCurrentTime(): string {
249	const now = new Date();
250	const hours = String(now.getHours()).padStart(2, "0");
251	const minutes = String(now.getMinutes()).padStart(2, "0");
252	return `${hours}:${minutes}`;
253}
254
255// =============================================================================
256// Extension
257// =============================================================================
258
259export default function (pi: ExtensionAPI) {
260	let timeUpdateInterval: NodeJS.Timeout | null = null;
261	let autoCompactEnabled = false;
262
263	// Track auto-compact status
264	pi.on("session_start", async (_event, ctx) => {
265		// Check if auto-compact is enabled from settings
266		autoCompactEnabled = ctx.sessionManager.getAutoCompact?.() || false;
267	});
268
269	pi.on("session_start", async (_event, ctx) => {
270		// Footer only makes sense in TUI mode (hasUI is also true in RPC).
271		if (ctx.mode !== "tui") return;
272		// Clear any existing interval
273		if (timeUpdateInterval) {
274			clearInterval(timeUpdateInterval);
275		}
276
277		ctx.ui.setFooter((tui, theme, footerData) => {
278			const unsub = footerData.onBranchChange(() => tui.requestRender());
279
280			// Update time and status every 10 seconds
281			// This also ensures extension statuses appear promptly
282			timeUpdateInterval = setInterval(() => {
283				tui.requestRender();
284			}, 10000); // 10 seconds
285
286			return {
287				dispose: () => {
288					unsub();
289					if (timeUpdateInterval) {
290						clearInterval(timeUpdateInterval);
291						timeUpdateInterval = null;
292					}
293				},
294				invalidate() {},
295				render(width: number): string[] {
296					try {
297					// Get all info
298					const time = getCurrentTime();
299					const host = hostname();
300					const folder = abbreviatePath(ctx.cwd);
301					const git = getGitInfo();
302					const modelId = ctx.model?.id || "no-model";
303					const provider = getProviderName(ctx.model);
304					const modelName = getShortModelName(modelId);
305					const thinking = getThinkingInfo(pi);
306					const contextInfo = getContextInfo(ctx, autoCompactEnabled);
307
308					// Build components
309					const timeText = theme.fg("dim", time);
310					const hostIcon = theme.fg("accent", "🖥️");
311					const hostText = theme.fg("accent", host);
312					const folderText = theme.fg("dim", folder);
313					
314					const gitBranch = git.branch
315						? theme.fg("success", git.branch + (git.dirty ? "*" : "") + (git.worktree ? " 🌳" : ""))
316						: "";
317					
318					// Provider/Model format: google-vertex/sonnet-4.5
319					const providerModel = theme.fg("accent", `${provider}/${modelName}`);
320					
321					// Thinking indicator (only if enabled)
322					const thinkingText = thinking 
323						? theme.fg("accent", `${thinking.emoji} ${thinking.level}`)
324						: "";
325					
326					// Context usage: 76.2%/200k (auto)
327					const contextText = contextInfo ? theme.fg("dim", contextInfo) : "";
328					
329					// Extension statuses (from setStatus calls)
330					// getExtensionStatuses() returns ReadonlyMap<string, string>
331					const extensionStatuses = footerData.getExtensionStatuses?.() || new Map();
332					
333					// Extract mode separately for custom positioning and coloring
334					const modeRaw = extensionStatuses.get("mode");
335					const modeColor = extensionStatuses.get("mode-color");
336					const modeText = modeRaw ? getModeText(modeRaw, modeColor, theme) : "";
337
338					// Remaining statuses (exclude mode internals)
339					const statusTexts = Array.from(extensionStatuses.entries())
340						.filter(([key, val]: [string, string]) => key !== "mode" && key !== "mode-color" && Boolean(val))
341						.map(([, val]: [string, string]) => val);
342
343					// Adaptive footer: progressively drop components on narrow terminals
344					const components = width < 60
345						? [timeText, modeText, contextText]
346						: width < 80
347						? [timeText, folderText, gitBranch, modeText, providerModel, contextText]
348						: width < 100
349						? [timeText, `${hostIcon} ${hostText}`, folderText, gitBranch, modeText, providerModel, contextText]
350						: [timeText, `${hostIcon} ${hostText}`, folderText, gitBranch, modeText, providerModel, thinkingText, contextText, ...statusTexts];
351
352					const separator = theme.fg("dim", "  ");
353					const footer = components.filter(Boolean).join(separator);
354
355					return [truncateToWidth(footer, width)];
356					} catch { return [""]; }
357				},
358			};
359		});
360	});
361
362	pi.on("session_switch", async (_event, ctx) => {
363		// Re-setup footer on session switch
364		if (ctx.mode !== "tui") return;
365		if (timeUpdateInterval) {
366			clearInterval(timeUpdateInterval);
367		}
368
369		ctx.ui.setFooter((tui, theme, footerData) => {
370			const unsub = footerData.onBranchChange(() => tui.requestRender());
371
372			timeUpdateInterval = setInterval(() => {
373				tui.requestRender();
374			}, 60000);
375
376			return {
377				dispose: () => {
378					unsub();
379					if (timeUpdateInterval) {
380						clearInterval(timeUpdateInterval);
381						timeUpdateInterval = null;
382					}
383				},
384				invalidate() {},
385				render(width: number): string[] {
386					try {
387					// Get all info
388					const time = getCurrentTime();
389					const host = hostname();
390					const folder = abbreviatePath(ctx.cwd);
391					const git = getGitInfo();
392					const modelId = ctx.model?.id || "no-model";
393					const provider = getProviderName(ctx.model);
394					const modelName = getShortModelName(modelId);
395					const thinking = getThinkingInfo(pi);
396					const contextInfo = getContextInfo(ctx, autoCompactEnabled);
397
398					// Build components
399					const timeText = theme.fg("dim", time);
400					const hostIcon = theme.fg("accent", "🖥️");
401					const hostText = theme.fg("accent", host);
402					const folderText = theme.fg("dim", folder);
403					
404					const gitBranch = git.branch
405						? theme.fg("success", git.branch + (git.dirty ? "*" : "") + (git.worktree ? " 🌳" : ""))
406						: "";
407					
408					// Provider/Model format: google-vertex/sonnet-4.5
409					const providerModel = theme.fg("accent", `${provider}/${modelName}`);
410					
411					// Thinking indicator (only if enabled)
412					const thinkingText = thinking 
413						? theme.fg("accent", `${thinking.emoji} ${thinking.level}`)
414						: "";
415					
416					// Context usage: 76.2%/200k (auto)
417					const contextText = contextInfo ? theme.fg("dim", contextInfo) : "";
418
419					// Extension statuses
420					const extensionStatuses = footerData.getExtensionStatuses?.() || new Map();
421					const modeRaw = extensionStatuses.get("mode");
422					const modeColor = extensionStatuses.get("mode-color");
423					const modeText = modeRaw ? getModeText(modeRaw, modeColor, theme) : "";
424					const statusTexts = Array.from(extensionStatuses.entries())
425						.filter(([key, val]: [string, string]) => key !== "mode" && key !== "mode-color" && Boolean(val))
426						.map(([, val]: [string, string]) => val);
427					// Adaptive footer: progressively drop components on narrow terminals
428					const components = width < 60
429						? [timeText, modeText, contextText]
430						: width < 80
431						? [timeText, folderText, gitBranch, modeText, providerModel, contextText]
432						: width < 100
433						? [timeText, `${hostIcon} ${hostText}`, folderText, gitBranch, modeText, providerModel, contextText]
434						: [timeText, `${hostIcon} ${hostText}`, folderText, gitBranch, modeText, providerModel, thinkingText, contextText, ...statusTexts];
435
436					const separator = theme.fg("dim", "  ");
437					const footer = components.filter(Boolean).join(separator);
438
439					return [truncateToWidth(footer, width)];
440					} catch { return [""]; }
441				},
442			};
443		});
444	});
445
446	pi.on("session_shutdown", async () => {
447		if (timeUpdateInterval) {
448			clearInterval(timeUpdateInterval);
449			timeUpdateInterval = null;
450		}
451	});
452}