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}