import type { ExtensionAPI, ReadonlyFooterDataProvider, Theme, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui"; import type { TUI } from "@earendil-works/pi-tui"; import type { SegmentContext, StatusLineSegmentId, UsageStats, SessionStats, SessionEvent, ThinkingLevelEvent, AssistantMessageEvent, ToolResultEvent, UserBashEvent } from "./types.js"; import { renderSegment } from "./segments/index.js"; import { createAgentStatusTracker } from "./segments/status.js"; import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js"; import { getEffectiveConfig } from "./config.js"; import { getIcons } from "./icons.js"; import { getDefaultColors, fg } from "./theme.js"; import { showFooterConfigurator } from "./configure.js"; const GIT_BRANCH_PATTERNS: RegExp[] = [ /\bgit\s+(checkout|switch|branch\s+-[dDmM]|merge|rebase|pull|reset|worktree)/, /\bgit\s+stash\s+(pop|apply)/, ]; // ═══════════════════════════════════════════════════════════════════════════ // Status Line Builder // ═══════════════════════════════════════════════════════════════════════════ /** Render a single segment and return its content with width */ function renderSegmentWithWidth( segId: StatusLineSegmentId, ctx: SegmentContext ): { content: string; width: number; visible: boolean } { const rendered = renderSegment(segId, ctx); if (!rendered.visible || !rendered.content) { return { content: "", width: 0, visible: false }; } return { content: rendered.content, width: visibleWidth(rendered.content), visible: true }; } /** * Build footer content from left and right segments. * Left segments are left-aligned, right segments are right-aligned. */ function buildFooterContent( ctx: SegmentContext, leftSegments: StatusLineSegmentId[], rightSegments: StatusLineSegmentId[], availableWidth: number, ): string { const maxContentWidth = Math.max(0, availableWidth - 2); // Render left segments const leftParts: string[] = []; for (const segId of leftSegments) { const { content, visible } = renderSegmentWithWidth(segId, ctx); if (visible) { leftParts.push(content); } } // Render right segments const rightParts: string[] = []; let rightWidth = 0; for (const segId of rightSegments) { const { content, width, visible } = renderSegmentWithWidth(segId, ctx); if (visible) { rightParts.push(content); rightWidth += width + 1; // +1 for space between } } if (rightParts.length > 0) { rightWidth -= 1; // Remove trailing space } let leftStr = leftParts.join(" "); let rightStr = rightParts.join(" "); // Handle case with no right segments if (rightParts.length === 0) { const finalLeft = truncateToWidth(leftStr, maxContentWidth); return " " + finalLeft + " ".repeat(Math.max(0, maxContentWidth - visibleWidth(finalLeft))) + " "; } // If right side alone is too big, just show right side if (rightWidth >= maxContentWidth) { return " " + truncateToWidth(rightStr, maxContentWidth) + " "; } // Ensure at least 1 space between left and right const maxLeftWidth = maxContentWidth - rightWidth - 1; const finalLeft = truncateToWidth(leftStr, Math.max(0, maxLeftWidth)); const finalLeftWidth = visibleWidth(finalLeft); const padding = maxContentWidth - finalLeftWidth - rightWidth; const result = " " + finalLeft + " ".repeat(padding) + rightStr + " "; return truncateToWidth(result, availableWidth); } // ═══════════════════════════════════════════════════════════════════════════ // Extension // ═══════════════════════════════════════════════════════════════════════════ export default function footer(pi: ExtensionAPI) { let sessionStartTime = Date.now(); let currentCtx: ExtensionContext | null = null; let footerDataRef: ReadonlyFooterDataProvider | null = null; let lastBranchLength = 0; let cachedUsageStats: UsageStats | null = null; let tuiRef: TUI | null = null; // Session stats accumulators (timing only; counts are reconstructed from the branch) let llmMs = 0; let toolMs = 0; let ttftSamples: number[] = []; let turnStartMs = 0; let toolMsThisTurn = 0; let ttftRecordedThisTurn = false; let agentStartMs = 0; let lastTurnaroundMs = 0; let statusTracker = createAgentStatusTracker(); const toolStartTimes = new Map(); pi.registerCommand("configure-opl", { description: "Interactively configure the OPL footer layout", handler: async (_args, ctx) => { await showFooterConfigurator(ctx, () => tuiRef?.requestRender()); }, }); // Track session start pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => { sessionStartTime = Date.now(); currentCtx = ctx; lastBranchLength = 0; cachedUsageStats = null; llmMs = 0; toolMs = 0; ttftSamples = []; turnStartMs = 0; toolMsThisTurn = 0; ttftRecordedThisTurn = false; agentStartMs = 0; lastTurnaroundMs = 0; statusTracker = createAgentStatusTracker(); toolStartTimes.clear(); if (ctx.hasUI) { setupFooter(ctx); } }); // Track user-prompt-to-completion turnaround. agent_start may fire multiple // times per user turn (retries/compaction); only the first since the last // settle marks when the user's prompt began processing. agent_settled is the // authoritative "fully done, no auto-continuation" signal. pi.on("agent_start", async (_event: unknown, _ctx: ExtensionContext) => { if (agentStartMs === 0) agentStartMs = Date.now(); statusTracker.agentStarted(); tuiRef?.requestRender(); }); pi.on("agent_settled", async (_event: unknown, _ctx: ExtensionContext) => { if (agentStartMs > 0) { lastTurnaroundMs = Date.now() - agentStartMs; agentStartMs = 0; } statusTracker.agentSettled(); tuiRef?.requestRender(); }); pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => { turnStartMs = Date.now(); toolMsThisTurn = 0; ttftRecordedThisTurn = false; }); pi.on("turn_end", async (_event: unknown, _ctx: ExtensionContext) => { const elapsed = Date.now() - turnStartMs; llmMs += Math.max(0, elapsed - toolMsThisTurn); }); pi.on("tool_execution_start", async (event: { toolCallId: string }, _ctx: ExtensionContext) => { toolStartTimes.set(event.toolCallId, Date.now()); statusTracker.toolStarted(event.toolCallId); tuiRef?.requestRender(); }); pi.on("tool_execution_end", async (event: { toolCallId: string }, _ctx: ExtensionContext) => { const start = toolStartTimes.get(event.toolCallId); if (start !== undefined) { const elapsed = Date.now() - start; toolMs += elapsed; toolMsThisTurn += elapsed; toolStartTimes.delete(event.toolCallId); } statusTracker.toolEnded(event.toolCallId); tuiRef?.requestRender(); }); pi.on("message_update", async (_event: unknown, _ctx: ExtensionContext) => { if (!ttftRecordedThisTurn && turnStartMs > 0) { ttftSamples.push(Date.now() - turnStartMs); ttftRecordedThisTurn = true; } }); // Invalidate git status on file changes pi.on("tool_result", async (event: ToolResultEvent, _ctx: ExtensionContext) => { if (event.toolName === "write" || event.toolName === "edit") { invalidateGitStatus(); } if (event.toolName === "bash" && event.input?.command) { const cmd = String(event.input.command); if (GIT_BRANCH_PATTERNS.some(p => p.test(cmd))) { invalidateGitStatus(); invalidateGitBranch(); setTimeout(() => tuiRef?.requestRender(), 100); } } }); // Also catch user escape commands (! prefix) pi.on("user_bash", async (event: UserBashEvent, _ctx: ExtensionContext) => { if (GIT_BRANCH_PATTERNS.some(p => p.test(event.command))) { invalidateGitStatus(); invalidateGitBranch(); tuiRef?.requestRender(); } }); function buildSegmentContext(ctx: ExtensionContext, width: number, theme: Theme): SegmentContext { const effectiveConfig = getEffectiveConfig(); const colors = effectiveConfig.colors ?? getDefaultColors(); const branch = (ctx.sessionManager?.getBranch?.() ?? []) as SessionEvent[]; const branchLen = branch.length; const isAssistantMessageEvent = (e: SessionEvent): e is AssistantMessageEvent => e.type === "message" && (e as AssistantMessageEvent).message.role === "assistant"; const completedMessages = branch .filter(isAssistantMessageEvent) .map(e => e.message as AssistantMessage) .filter(m => m.stopReason !== "error" && m.stopReason !== "aborted"); // Cache usageStats — only recompute when branch grows let usageStats: UsageStats; if (cachedUsageStats && branchLen === lastBranchLength) { usageStats = cachedUsageStats; } else { usageStats = completedMessages.reduce( (acc, m) => ({ input: acc.input + m.usage.input, output: acc.output + m.usage.output, cacheRead: acc.cacheRead + m.usage.cacheRead, cacheWrite: acc.cacheWrite + m.usage.cacheWrite, cost: acc.cost + m.usage.cost.total, }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, ); cachedUsageStats = usageStats; lastBranchLength = branchLen; } const isThinkingEvent = (e: SessionEvent): e is ThinkingLevelEvent => e.type === "thinking_level_change"; const thinkingLevelFromSession = branch .filter(isThinkingEvent) .reduce((_, e) => e.thinkingLevel ?? "off", "off"); const lastAssistant = completedMessages.at(-1); // Calculate context percentage const contextTokens = lastAssistant ? lastAssistant.usage.input + lastAssistant.usage.output + lastAssistant.usage.cacheRead + lastAssistant.usage.cacheWrite : 0; const contextWindow = ctx.model?.contextWindow || 0; const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; // Get git status (cached) const gitBranch = footerDataRef?.getGitBranch() ?? null; const gitStatus = getGitStatus(gitBranch); // Check if using OAuth subscription const usingSubscription = ctx.model ? ctx.modelRegistry?.isUsingOAuth?.(ctx.model) ?? false : false; // Local model detection: use model ID prefix (local.*) instead of URL // This allows litellm proxy to correctly separate local vs cloud models const isLocalModel = ctx.model?.id?.startsWith?.("local.") ?? false; // Reconstruct prompt/API-call/tool-call counts from the branch so they survive quit/resume. // Timing stats (llmMs, toolMs, ttftSamples) are ephemeral — not stored in messages. const branchPrompts = branch.filter( (e) => e.type === "message" && (e as AssistantMessageEvent).message?.role === "user", ).length; const branchApiCalls = completedMessages.length; // Count tool calls from assistant message content const branchToolCalls = completedMessages.reduce((sum, msg) => { const content = (msg as any).content || []; return sum + content.filter((block: any) => block?.type === "toolCall").length; }, 0); return { model: ctx.model, isLocalModel, thinkingLevel: thinkingLevelFromSession || pi.getThinkingLevel(), sessionId: ctx.sessionManager?.getSessionId?.(), usageStats, contextPercent, contextWindow, usingSubscription, sessionStartTime, git: gitStatus, options: effectiveConfig.segmentOptions ?? {}, width, theme, colors, icons: getIcons(effectiveConfig.icons), sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs }, agentStatus: statusTracker.status(), }; } function setupFooter(ctx: ExtensionContext) { ctx.ui.setFooter((tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => { footerDataRef = footerData; tuiRef = tui; // Expose a re-render trigger for out-of-turn state changes (e.g. /caveman toggle). (globalThis as Record).__footerRequestRender = () => tui.requestRender(); // Subscribe to branch changes for re-render const unsub = footerData.onBranchChange(() => tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { if (!currentCtx) return []; const effectiveConfig = getEffectiveConfig(); let segmentCtx; try { segmentCtx = buildSegmentContext(currentCtx, width, theme); } catch { return []; } const row1 = buildFooterContent( segmentCtx, effectiveConfig.row1LeftSegments, effectiveConfig.row1RightSegments, width, ); const row2 = buildFooterContent( segmentCtx, effectiveConfig.row2LeftSegments, effectiveConfig.row2RightSegments, width, ); const row3 = buildFooterContent( segmentCtx, effectiveConfig.row3LeftSegments, effectiveConfig.row3RightSegments, width, ); const divider = fg(theme, "separator", "─".repeat(width), segmentCtx.colors); return ["", row1, divider, row2, divider, row3]; }, }; }); } }