import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import type { Component } from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fg, label, resolveContextColor, resolveUsageColor, bar } from "./colors.js"; import { getProviderLimitInfo, readCodexReasoningEffort } from "./codex.js"; import { getGitInfo } from "./git.js"; import { normalizeThinkingLevel } from "./thinking.js"; import type { ContextInfo, GitInfo, HudConfig, HudState, HudSurface, ModelInfo, ProviderLimitInfo, SessionStats, ToolRun, UsageLimitWindow } from "./types.js"; import { clamp, formatElapsedSpaced, formatTokensPrecise, numberValue, truncatePlain } from "./utils.js"; export class UniversalHudStatusline implements Component { constructor( private pi: ExtensionAPI, private getCtx: () => ExtensionContext | null, private getConfig: () => HudConfig, private hudState: HudState, private theme: Theme, private surface: HudSurface, ) {} invalidate(): void {} render(width: number): string[] { const ctx = this.getCtx(); const currentConfig = this.getConfig(); if (!ctx || !currentConfig.enabled) return []; return renderHud(this.pi, ctx, currentConfig, this.hudState, this.theme, width, this.surface); } } function renderHud( pi: ExtensionAPI, ctx: ExtensionContext, cfg: HudConfig, hudState: HudState, theme: Theme, width: number, surface: HudSurface, ): string[] { const safeWidth = Math.max(1, width); const lines = cfg.lineLayout === "compact" ? renderCompact(pi, ctx, cfg, hudState, theme) : renderExpanded(pi, ctx, cfg, hudState, theme, safeWidth); const withSeparators = addOptionalSeparators(lines, cfg.showSeparators, theme); const physicalLines = withSeparators.flatMap((line) => wrapLine(line, safeWidth)); physicalLines.push("", ""); return physicalLines.map((line) => fitLine(line, safeWidth, theme, surface)); } function renderExpanded( pi: ExtensionAPI, ctx: ExtensionContext, cfg: HudConfig, hudState: HudState, theme: Theme, _width: number, ): string[] { return [ renderTopLine(pi, ctx, cfg, hudState, theme), renderContextAndLimitsLine(ctx, cfg, theme), renderEnvironmentCountsLine(ctx, theme), renderClaudeStyleToolsLine(hudState, theme), renderTokensLine(ctx, theme), ].filter((line): line is string => Boolean(line)); } function renderCompact( pi: ExtensionAPI, ctx: ExtensionContext, cfg: HudConfig, hudState: HudState, theme: Theme, ): string[] { const tools = renderClaudeStyleToolsLine(hudState, theme); return [ renderTopLine(pi, ctx, cfg, hudState, theme), renderContextAndLimitsLine(ctx, cfg, theme), tools, ].filter((line): line is string => Boolean(line)); } function renderTopLine(pi: ExtensionAPI, ctx: ExtensionContext, cfg: HudConfig, hudState: HudState, theme: Theme): string { const model = getModelInfo(ctx); const cwd = resolveCwd(ctx); const modelBadge = fg(theme, "model", `[${formatShortModelLabel(model.label)}]`); const thinkingBadge = getCompactThinkingLabel(pi, model); const modelPart = [modelBadge, thinkingBadge ? label(theme, thinkingBadge) : null].filter(Boolean).join(" "); const project = cfg.display.showProject ? fg(theme, "project", formatProjectPath(cwd, cfg.pathLevels)) : null; const git = cfg.display.showProject ? formatGitInline(getGitInfo(cwd), theme) : null; const sessionName = formatSessionName(ctx); const duration = `${timerIcon()} ${formatElapsedSpaced(hudState.sessionStartedAt)}`; return [modelPart, [project, git].filter(Boolean).join(" "), label(theme, sessionName), label(theme, duration)] .filter(Boolean) .join(" │ "); } function renderContextAndLimitsLine(ctx: ExtensionContext, cfg: HudConfig, theme: Theme): string { const model = getModelInfo(ctx); const stats = computeStats(ctx); const context = resolveContext(ctx, stats, model); const contextPercent = context.percent ?? 0; const contextPart = `Context ${formatHudBar(context.percent, 14, theme, "context")} ${fg(theme, resolveContextColor(context.percent), formatPercent(context.percent))}`; const limitInfo = cfg.display.showLimits ? getProviderLimitInfo(model) : null; const primary = limitInfo?.windows[0]; const secondary = limitInfo?.windows[1]; const usagePart = primary ? `Usage ${formatHudBar(primary.usedPercent, 10, theme, "usage")} ${fg(theme, resolveUsageColor(primary.usedPercent), `${Math.round(primary.usedPercent)}%`)}${formatResetSuffix(primary)}` : `Usage ${formatHudBar(null, 10, theme, "usage")} ${label(theme, "n/a")}`; const weeklyPart = secondary ? `Weekly ${formatHudBar(secondary.usedPercent, 10, theme, "usage")} ${fg(theme, resolveUsageColor(secondary.usedPercent), `${Math.round(secondary.usedPercent)}%`)}${formatResetSuffix(secondary)}` : `Weekly ${formatHudBar(null, 10, theme, "usage")} ${label(theme, "n/a")}`; return [contextPart, usagePart, weeklyPart].join(" │ "); } function renderEnvironmentCountsLine(ctx: ExtensionContext, theme: Theme): string { const cwd = resolveCwd(ctx); const contextFiles = countContextFiles(cwd); const mcpCount = countMcpServers(cwd); const hookCount = countPiHooks(cwd); return [ label(theme, formatContextFileCount(contextFiles)), label(theme, `${mcpCount} MCPs`), label(theme, `${hookCount} hooks`), ].join(" │ "); } function renderClaudeStyleToolsLine(hudState: HudState, theme: Theme): string | null { const running = [...hudState.activeTools.values()].filter((tool) => !tool.isAgent); const parts: string[] = []; for (const tool of running.slice(-2)) { const target = tool.target ? label(theme, `: ${truncatePlain(tool.target, 28)}`) : ""; parts.push(`${fg(theme, "warning", "◐")} ${formatToolName(tool.name)}${target}`); } const sortedCounts = [...hudState.toolCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 5); for (const [name, count] of sortedCounts) { parts.push(`${fg(theme, "success", "✓")} ${formatToolName(name)} ${label(theme, `×${count}`)}`); } const sortedErrors = [...hudState.failedToolCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 2); for (const [name, count] of sortedErrors) { parts.push(`${fg(theme, "error", "✗")} ${formatToolName(name)} ${label(theme, `×${count}`)}`); } return parts.length > 0 ? parts.join(" │ ") : `${fg(theme, "success", "✓")} Ready`; } function renderTokensLine(ctx: ExtensionContext, theme: Theme): string { const stats = computeStats(ctx); const cacheTokens = stats.cacheReadTokens + stats.cacheWriteTokens; const total = stats.totalTokens || stats.inputTokens + stats.outputTokens + cacheTokens; return label( theme, `Tokens ${formatTokensPrecise(total)} (in: ${formatTokensPrecise(stats.inputTokens)}, out: ${formatTokensPrecise(stats.outputTokens)}, cache: ${formatTokensPrecise(cacheTokens)})`, ); } function formatShortModelLabel(labelValue: string): string { return labelValue .replace(/^Claude\s+/i, "") .replace(/\s*\([^)]*context[^)]*\)/i, "") .trim(); } function formatGitInline(git: GitInfo, theme: Theme): string | null { if (!git.branch) return null; const parts = [git.branch]; if (git.dirty) parts.push("*"); if (git.ahead > 0) parts.push(` ↑${git.ahead}`); if (git.behind > 0) parts.push(` ↓${git.behind}`); return `${fg(theme, "git", "git:(")}${fg(theme, "gitBranch", parts.join(""))}${fg(theme, "git", ")")}`; } function formatSessionName(ctx: ExtensionContext): string { const manager = ctx.sessionManager as any; const sessionName = String(manager.getSessionName?.() ?? "").trim(); if (sessionName && sessionName !== "New session") return sessionName; const sessionId = String(manager.getSessionId?.() ?? "session"); return sessionId === "unknown" ? "session" : sessionId.slice(0, 8); } function timerIcon(): string { return "⏱"; } function formatHudBar(percent: number | null, width: number, theme: Theme, kind: "context" | "usage"): string { return bar(theme, kind, percent === null ? null : clamp(percent, 0, 100), width); } function formatPercent(percent: number | null): string { return percent === null ? "n/a" : `${Math.round(percent)}%`; } function formatResetSuffix(window: UsageLimitWindow): string { if (!window.resetAt) return ""; if (window.resetAt <= Date.now() / 1000) return " (stale)"; return ` (resets in ${formatResetDistance(window.resetAt)})`; } function countContextFiles(cwd: string): { agents: number; claude: number } { let agents = 0; let claude = 0; const seen = new Set(); for (const dir of getAncestorDirs(cwd)) { for (const file of ["AGENTS.md", "CLAUDE.md"]) { const fullPath = path.join(dir, file); if (seen.has(fullPath)) continue; seen.add(fullPath); if (!fs.existsSync(fullPath)) continue; if (file === "AGENTS.md") agents++; else claude++; } } const globalAgents = path.join(os.homedir(), ".pi", "agent", "AGENTS.md"); if (fs.existsSync(globalAgents)) agents++; return { agents, claude }; } function formatContextFileCount(counts: { agents: number; claude: number }): string { if (counts.claude > 0 && counts.agents === 0) return `${counts.claude} CLAUDE.md`; if (counts.agents > 0 && counts.claude === 0) return `${counts.agents} AGENTS.md`; const total = counts.agents + counts.claude; return total === 0 ? "0 AGENTS.md" : `${total} context files`; } function countMcpServers(cwd: string): number { const serverNames = new Set(); for (const file of [ path.join(os.homedir(), ".config", "mcp", "mcp.json"), path.join(os.homedir(), ".pi", "agent", "mcp.json"), path.join(cwd, ".mcp.json"), path.join(cwd, ".pi", "mcp.json"), ]) { const json = readJsonFile(file); const servers = (json as any)?.mcpServers ?? (json as any)?.servers; if (!servers || typeof servers !== "object") continue; for (const name of Object.keys(servers)) serverNames.add(name); } return serverNames.size; } function countPiHooks(cwd: string): number { let count = 0; for (const file of [ path.join(os.homedir(), ".pi", "agent", "settings.json"), path.join(cwd, ".pi", "settings.json"), ]) { const hooks = (readJsonFile(file) as any)?.hooks; count += countHookEntries(hooks); } return count; } function countHookEntries(value: unknown): number { if (!value) return 0; if (Array.isArray(value)) return value.length; if (typeof value === "object") { let count = 0; for (const child of Object.values(value as Record)) { count += countHookEntries(child); } return count; } return 0; } function readJsonFile(filePath: string): unknown { try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return null; } } function getAncestorDirs(cwd: string): string[] { const dirs: string[] = []; let current = path.resolve(cwd); while (true) { dirs.push(current); const parent = path.dirname(current); if (parent === current) break; current = parent; } return dirs; } function formatToolName(name: string): string { if (!name) return "Tool"; if (name === "bash") return "Bash"; if (name === "read") return "Read"; if (name === "write") return "Write"; if (name === "edit") return "Edit"; if (name === "grep") return "Grep"; if (name === "find") return "Find"; if (name === "ls") return "Ls"; return name.slice(0, 1).toUpperCase() + name.slice(1); } function addOptionalSeparators(lines: string[], enabled: boolean, theme: Theme): string[] { if (!enabled || lines.length <= 2) return lines; const result: string[] = []; for (let i = 0; i < lines.length; i++) { if (i === 3) result.push(fg(theme, "dim", "─".repeat(24))); result.push(lines[i]); } return result; } function wrapLine(line: string, width: number): string[] { if (visibleWidth(line) <= width) return [line]; const parts = line.split(" │ "); if (parts.length <= 1) return [truncateToWidth(line, width, "…", true)]; const result: string[] = []; let current = parts[0] ?? ""; for (const part of parts.slice(1)) { const candidate = `${current} │ ${part}`; if (visibleWidth(candidate) <= width) { current = candidate; } else { result.push(truncateToWidth(current, width, "…", true)); current = part; } } if (current) result.push(truncateToWidth(current, width, "…", true)); return result; } function fitLine(line: string, width: number, _theme: Theme, _surface: HudSurface): string { const leftPadding = " "; const contentWidth = Math.max(1, width - visibleWidth(leftPadding)); const truncated = truncateToWidth(line, contentWidth, "…", true); const padded = `${leftPadding}${truncated}`; return `${padded}${" ".repeat(Math.max(0, width - visibleWidth(padded)))}`; } function computeStats(ctx: ExtensionContext): SessionStats { const stats: SessionStats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0, cost: 0, }; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message" || entry.message.role !== "assistant") continue; const message = entry.message as AssistantMessage & { usage?: any }; const usage = message.usage ?? {}; const input = numberValue(usage.input, usage.inputTokens, usage.promptTokens); const output = numberValue(usage.output, usage.outputTokens, usage.completionTokens); const cacheRead = numberValue(usage.cacheRead, usage.cacheReadTokens); const cacheWrite = numberValue(usage.cacheWrite, usage.cacheWriteTokens); stats.inputTokens += input; stats.outputTokens += output; stats.cacheReadTokens += cacheRead; stats.cacheWriteTokens += cacheWrite; stats.totalTokens += numberValue(usage.totalTokens, usage.total, input + output + cacheRead + cacheWrite); stats.cost += numberValue(usage.cost?.total, usage.totalCost, typeof usage.cost === "number" ? usage.cost : 0); } return stats; } function resolveContext(ctx: ExtensionContext, stats: SessionStats, model: ModelInfo): ContextInfo { const usage = ctx.getContextUsage?.(); const tokens = numberValue(usage?.tokens, stats.totalTokens); const window = numberValue(usage?.contextWindow, model.contextWindow); const percent = typeof usage?.percent === "number" ? clamp(usage.percent, 0, 100) : window > 0 ? clamp((tokens / window) * 100, 0, 100) : null; const remainingTokens = window > 0 ? Math.max(0, window - tokens) : null; return { tokens, window, percent, remainingTokens }; } function getModelInfo(ctx: ExtensionContext): ModelInfo { const model = (ctx as { model?: any }).model; const raw = [model?.provider, model?.providerId, model?.id, model?.name, model?.displayName] .filter(Boolean) .join(" ") .toLowerCase(); const family = raw.includes("openai") || raw.includes("codex") || /(^|[^a-z])gpt|\bo[1345]/.test(raw) ? "openai" : raw.includes("anthropic") || raw.includes("claude") || raw.includes("sonnet") || raw.includes("opus") || raw.includes("haiku") ? "claude" : "other"; const provider = family === "openai" ? "OpenAI" : family === "claude" ? "Claude" : String(model?.provider ?? "Provider"); return { family, provider, label: String(model?.name ?? model?.displayName ?? model?.id ?? "No model"), contextWindow: numberValue(model?.contextWindow), maxOutputTokens: numberValue(model?.maxTokens, model?.maxOutputTokens), reasoning: model?.reasoning === true, }; } function getCompactThinkingLabel(pi: ExtensionAPI, model: ModelInfo): string | null { const level = getThinkingLevelValue(pi, model); if (level) return `think:${level}`; return model.reasoning ? "reasoning" : null; } function getThinkingLevelValue(pi: ExtensionAPI, model: ModelInfo): string | null { if (model.reasoning) { const level = normalizeThinkingLevel((pi as { getThinkingLevel?: () => string | undefined }).getThinkingLevel?.()); if (level) return level; } if (model.family === "openai") return readCodexReasoningEffort(); return null; } function resolveCwd(ctx: ExtensionContext): string { const manager = ctx.sessionManager as any; return manager.getCwd?.() || ctx.cwd || process.cwd(); } function formatProjectPath(cwd: string, levels: number): string { const segments = cwd.split(/[\\/]/).filter(Boolean); if (segments.length === 0) return cwd; return segments.slice(-levels).join("/"); } function formatResetDistance(resetAtSeconds: number): string { const diffSeconds = Math.round(resetAtSeconds - Date.now() / 1000); if (diffSeconds <= 0) return "now"; if (diffSeconds < 3600) return `${Math.ceil(diffSeconds / 60)}m`; if (diffSeconds < 86400) { const hours = Math.floor(diffSeconds / 3600); const minutes = Math.floor((diffSeconds % 3600) / 60); return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`; } const days = Math.floor(diffSeconds / 86400); const hours = Math.floor((diffSeconds % 86400) / 3600); return hours > 0 ? `${days}d${hours}h` : `${days}d`; } // Kept for future expanded usage-line work. Useful when rendering all provider // limit windows outside the compact Context/Usage/Weekly line. function formatProviderLimitInfo(info: ProviderLimitInfo, theme: Theme): string { const prefix = info.source === "codex-log" ? "limits" : "limits"; return `${prefix} ${info.windows.map((window) => formatLimitWindow(window, theme)).join(" │ ")}`; } function formatLimitWindow(window: UsageLimitWindow, theme: Theme): string { const stale = Boolean(window.resetAt && window.resetAt <= Date.now() / 1000); const color = stale ? "label" : window.limitReached ? "critical" : resolveUsageColor(window.usedPercent); const reset = !window.resetAt ? "" : stale ? " stale" : ` reset ${formatResetDistance(window.resetAt)}`; return `${window.label}: ${fg(theme, color, `${Math.round(window.usedPercent)}%`)}${reset}`; } void formatProviderLimitInfo;