import type { AssistantMessage } from "@earendil-works/pi-ai"; import { truncateToWidth } from "@earendil-works/pi-tui"; import { formatThinkingLabel, formatTokens, getThinkingLevel, renderFooterLine, sanitizeStatusText, shortenPath, } from "./format"; import { iconCacheRead, iconCacheWrite, iconGit, iconThink, withIcon } from "./icons"; import { formatTps } from "./tps"; import type { GitStatus } from "./types"; // ── Status blacklist ────────────────────────────────────────────────────────── // Keys in this set will not be shown in the extension statuses line (line 3). // Common uses: // - "extmgr" - package manager status ("15 pkgs • auto update off") // - Keys for extensions whose status you want to hide const STATUS_KEY_BLACKLIST = new Set(["extmgr", "sm"]); // ── Agent mode helper ───────────────────────────────────────────────────────── const AGENT_MODE_ENTRY_TYPE = "agent-mode:active"; function getActiveAgentMode(entries: any[]): string | null { // Scan from most recent to oldest for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "custom" && entry.customType === AGENT_MODE_ENTRY_TYPE) { const mode = entry.data?.mode; if (mode === null) { // Clear marker — stop scanning, return no mode return null; } if (typeof mode === "string" && mode.length > 0) { return mode; } } } return null; } // ── Live state types ─────────────────────────────────────────────────────────── interface TpsState { isStreaming: boolean; tps: number; lastOutputTokens: number; lastInputTokens: number; } // ── Thinking label animation helpers ───────────────────────────────────────── const ANIM_BAND_WIDTH = 2; const ANIM_INTERVAL_MS = 150; type Rgb = [number, number, number]; /** Extract RGB from an ANSI-escaped string produced by theme.fg() */ function extractRgb(themed: string): Rgb | null { const match = themed.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/); if (!match) return null; return [parseInt(match[1]!, 10), parseInt(match[2]!, 10), parseInt(match[3]!, 10)]; } function blendRgb(a: Rgb, b: Rgb, t: number): Rgb { return [ Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t), ]; } function lightenRgb(c: Rgb, amount: number): Rgb { return [ Math.min(255, Math.round(c[0] + (255 - c[0]) * amount)), Math.min(255, Math.round(c[1] + (255 - c[1]) * amount)), Math.min(255, Math.round(c[2] + (255 - c[2]) * amount)), ]; } function rgbEsc(r: number, g: number, b: number): string { return `\x1b[38;2;${r};${g};${b}m`; } const RESET = "\x1b[0m"; /** * Shimmer sweep: a bright highlight band sweeps left→right across each character. * Used for the "max" thinking level. */ function shimmerText(text: string, frame: number, base: Rgb, highlight: Rgb): string { const totalWidth = text.length + ANIM_BAND_WIDTH * 2; const pos = frame % totalWidth; let result = ""; for (let i = 0; i < text.length; i++) { const dist = Math.abs(i - pos); const t = Math.max(0, 1 - dist / ANIM_BAND_WIDTH); const [r, g, b] = blendRgb(base, highlight, t); result += `${rgbEsc(r, g, b)}${text[i]}${RESET}`; } return result; } /** * Breathing pulse: smooth sine-wave oscillation between base and a lighter tint. * Period: 32 frames ≈ 4.8s at 150ms/frame. Used for the "xhigh" thinking level. */ function pulseText(text: string, frame: number, base: Rgb, lighter: Rgb): string { const t = (Math.sin((frame / 16) * Math.PI) + 1) / 2; // 0 → 1 → 0 const [r, g, b] = blendRgb(base, lighter, t * 0.55); // max 55% blend return `${rgbEsc(r, g, b)}${text}${RESET}`; } // ── Footer renderer factory ──────────────────────────────────────────────────── /** * Returns the footer descriptor object accepted by ctx.ui.setFooter(). * Encapsulates all render logic for the two-line status footer. */ export function createFooterRenderer( ctx: any, getGitStatus: () => GitStatus | null, requestRender: () => void, hasNerdFonts: boolean, getTpsState: () => TpsState, ) { return (_tui: any, theme: any, footerData: any) => { const unsub = footerData.onBranchChange(() => { requestRender(); }); // ── Animation state ─────────────────────────────────────────────────── let animFrame = 0; let animTimer: ReturnType | null = null; // Cached RGB values for animated levels (null = not yet resolved or theme changed) let maxBase: Rgb | null = null; let maxHighlight: Rgb | null = null; let xhighBase: Rgb | null = null; let xhighLighter: Rgb | null = null; function resolveAnimColors(): void { maxBase = extractRgb(theme.fg("thinkingMax", "\u2588")); if (maxBase) maxHighlight = lightenRgb(maxBase, 0.65); xhighBase = extractRgb(theme.fg("thinkingXhigh", "\u2588")); if (xhighBase) xhighLighter = lightenRgb(xhighBase, 0.5); } function startAnimTimer(): void { if (animTimer) return; animTimer = setInterval(() => { animFrame++; requestRender(); }, ANIM_INTERVAL_MS); } function stopAnimTimer(): void { if (animTimer) { clearInterval(animTimer); animTimer = null; } } resolveAnimColors(); return { dispose() { unsub(); stopAnimTimer(); }, invalidate() { // Re-resolve colors when theme changes maxBase = null; maxHighlight = null; xhighBase = null; xhighLighter = null; resolveAnimColors(); }, render(width: number): string[] { const sep = theme.fg("dim", " · "); const groupSep = theme.fg("dim", " | "); const sessionName = ctx.sessionManager.getSessionName(); const gitStatus = getGitStatus(); const tpsState = getTpsState(); // ── Line 1: path · branch | session name ───────────────────── const leftParts: string[] = [theme.fg("accent", shortenPath(ctx.cwd))]; if (gitStatus?.branch) { let gitText = theme.fg(gitStatus.dirty ? "warning" : "success", gitStatus.branch); if (gitStatus.dirty) gitText += theme.fg("warning", " *"); if (gitStatus.ahead) gitText += theme.fg("success", ` ↑${gitStatus.ahead}`); if (gitStatus.behind) gitText += theme.fg("error", ` ↓${gitStatus.behind}`); leftParts.push(gitText); } else { const branch = footerData.getGitBranch(); if (branch) { const branchText = hasNerdFonts ? withIcon(iconGit, branch) : branch; leftParts.push(theme.fg("muted", branchText)); } } const rightParts: string[] = []; if (sessionName) rightParts.push(theme.fg("dim", theme.italic(sessionName))); const locationLine = renderFooterLine(width, leftParts.join(sep), rightParts.join(sep)); // ── Line 2: token stats · tps · model ──────────────────────── // Aggregate token stats from all persisted entries let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; let latestCacheHitRate: number | undefined; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "message" && entry.message.role === "assistant") { const msg = entry.message as AssistantMessage; if (msg.stopReason === "error" || msg.stopReason === "aborted") continue; totalInput += msg.usage.input; totalOutput += msg.usage.output; totalCacheRead += msg.usage.cacheRead; totalCacheWrite += msg.usage.cacheWrite; totalCost += msg.usage.cost.total; const promptTokens = msg.usage.input + msg.usage.cacheRead + msg.usage.cacheWrite; latestCacheHitRate = promptTokens > 0 ? (msg.usage.cacheRead / promptTokens) * 100 : undefined; } } // Build three groups separated by pipes // Group 1: ↑input ↓output // Group 2: Rcache Wcache CH% $cost // Group 3: context% const { tps } = tpsState; const showTps = tps > 0; // Group 1: Read/Write tokens const group1Parts: string[] = []; if (totalInput) group1Parts.push(`↑${formatTokens(totalInput)}`); if (totalOutput) group1Parts.push(`↓${formatTokens(totalOutput)}`); const group1 = group1Parts.length > 0 ? group1Parts.join(" ") : null; // Group 2: Cache + Price const group2Parts: string[] = []; if (totalCacheRead) group2Parts.push(`${iconCacheRead}${formatTokens(totalCacheRead)}`); if (totalCacheWrite) group2Parts.push(`${iconCacheWrite}${formatTokens(totalCacheWrite)}`); if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) { group2Parts.push(`CH${latestCacheHitRate.toFixed(1)}%`); } const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false; if (totalCost || usingSubscription) { group2Parts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`); } const group2 = group2Parts.length > 0 ? group2Parts.join(" ") : null; // Group 3: Context usage const contextUsage = ctx.getContextUsage(); const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0; let group3: string | null = null; if (contextUsage && contextUsage.tokens !== null && contextWindow > 0) { const percent = contextUsage.percent !== null ? contextUsage.percent.toFixed(1) : ((contextUsage.tokens / contextWindow) * 100).toFixed(1); const contextText = `${percent}%/${formatTokens(contextWindow)}`; const percentValue = contextUsage.percent ?? (contextUsage.tokens / contextWindow) * 100; if (percentValue > 90) group3 = theme.fg("error", contextText); else if (percentValue > 70) group3 = theme.fg("warning", contextText); else group3 = contextText; } else if (contextUsage && contextUsage.percent !== null) { const percent = contextUsage.percent.toFixed(1); const contextText = `${percent}%/${formatTokens(contextWindow)}`; if (contextUsage.percent > 90) group3 = theme.fg("error", contextText); else if (contextUsage.percent > 70) group3 = theme.fg("warning", contextText); else group3 = contextText; } // Combine groups with pipes const groups = [group1, group2, group3].filter((g) => g !== null) as string[]; const leftStats = groups.join(groupSep); // Model + thinking level with icons const model = ctx.model; const rightPartsLine2: string[] = []; if (showTps) { const tpsColor = tps < 30 ? "error" : tps < 50 ? "warning" : "success"; rightPartsLine2.push(`⚡${theme.fg(tpsColor, formatTps(tps))}`); } if (model) { if (model.provider && footerData.getAvailableProviderCount() > 1) { rightPartsLine2.push(theme.fg("muted", `(${model.provider})`)); } let modelText = theme.bold(model.id || "unknown"); if (model.reasoning) { const thinkingLevel = getThinkingLevel(ctx); const thinking = formatThinkingLabel(thinkingLevel); if (thinking) { const thinkLabel = hasNerdFonts ? withIcon(iconThink, thinking) : thinking; // ── Animated or static thinking label ──────────────── let styledLabel: string; if (thinkingLevel === "max" && maxBase && maxHighlight) { startAnimTimer(); styledLabel = shimmerText(thinkLabel, animFrame, maxBase, maxHighlight); } else if (thinkingLevel === "xhigh" && xhighBase && xhighLighter) { startAnimTimer(); styledLabel = pulseText(thinkLabel, animFrame, xhighBase, xhighLighter); } else { // Static per-level color, stop animation timer if running stopAnimTimer(); const thinkingColorMap: Record = { minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax", }; const thinkColor = thinkingColorMap[thinkingLevel] ?? "dim"; styledLabel = theme.fg(thinkColor, thinkLabel); } modelText += `${sep}${styledLabel}`; } else { // Thinking is off — stop animation stopAnimTimer(); } } else { // No reasoning model — stop animation stopAnimTimer(); } // Append active agent mode after thinking label const agentMode = getActiveAgentMode(ctx.sessionManager.getEntries()); if (agentMode) { modelText += `${sep}${theme.fg("muted", `◈ ${agentMode}`)}`; } rightPartsLine2.push(modelText); } const statsLine = renderFooterLine(width, leftStats, rightPartsLine2.join(sep)); // ── Line 3 (optional): extension statuses ───────────────────── const lines = [locationLine, statsLine]; const extensionStatuses = footerData.getExtensionStatuses(); // Filter out blacklisted status keys const visibleStatuses = new Map( Array.from(extensionStatuses.entries() as [string, string][]).filter( ([key, value]) => !STATUS_KEY_BLACKLIST.has(key) && value.trim() !== "", ), ); if (visibleStatuses.size > 0) { const statusLine = Array.from(visibleStatuses.values()) .map((text) => sanitizeStatusText(text)) .filter((text) => text !== "") .sort((a, b) => a.localeCompare(b)) .join(" "); if (statusLine) lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); } return lines.filter((line) => line !== "").map((line) => truncateToWidth(line, width)); }, }; }; }