/** * Statusline rendering — pure render logic for the custom footer. * * Colors use truecolor hex when the terminal supports it, * falling back to theme colors otherwise. */ import type { ExtensionContext, ReadonlyFooterDataProvider, Theme, ThemeColor, } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { basename } from "node:path"; export type WorkspaceStats = { dirty: boolean; }; export type UsageTotals = { input: number; output: number; cost: number; }; export type SegmentKey = "model" | "cwd" | "branch" | "context" | "cost" | "usage" | "tps" | "statuses"; export type StatuslineConfig = { enabled: boolean; segments: Record; }; export const SEGMENT_DEFS: Array<{ key: SegmentKey; label: string }> = [ { key: "model", label: "Model + thinking level" }, { key: "cwd", label: "Current directory" }, { key: "branch", label: "Git branch (* = uncommitted changes)" }, { key: "context", label: "Context usage" }, { key: "cost", label: "Session cost" }, { key: "usage", label: "Cumulative tokens: in/out + cache hit rate" }, { key: "tps", label: "Output speed (tps)" }, { key: "statuses", label: "Extension statuses" }, ]; export function defaultConfig(): StatuslineConfig { return { enabled: true, segments: { model: true, cwd: true, branch: true, context: true, cost: true, usage: true, tps: true, statuses: true, }, }; } export type StatuslineRenderContext = { ctx: ExtensionContext; footerData: ReadonlyFooterDataProvider; theme: Theme; getThinkingLevel(): string; workspace: WorkspaceStats; usageTotals: UsageTotals; /** Cache hit rate of the latest assistant request, 0-100, or null if unknown. */ cacheHitRate: number | null; /** Whether auto-compaction is enabled (read from pi settings). */ autoCompact: boolean; lastTps: number | null; config: StatuslineConfig; }; const ICONS = { model: "\u{e26d}", context: "\u{f49b}", branch: "\u{f418}", folder: "\u{f024b}", warn: "\u{f071}", tps: "\u{f04c5}", // fa-bullseye: cache hit rate hitRate: "\u{f140}", // md-autorenew: auto-compaction enabled autoCompact: "\u{f006a}", }; const MODEL_HEX = "E3A869"; const PATH_HEX = "7CB7FF"; const BRANCH_HEX = "91CB91"; const CONTEXT_HEX = "B392F0"; const COST_HEX = "FF78A2"; const TPS_HEX = "6ED7D3"; const CACHE_HEX = "A6D189"; const SEPARATOR_HEX = "3B4048"; function formatCompactTokens(value: number): string { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`; if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; return `${value}`; } function formatTps(value: number | null): string { if (value === null || !Number.isFinite(value) || value <= 0) return "0.0 tps"; return `${value.toFixed(1)} tps`; } function formatCost(cost: number): string { if (cost <= 0) return "$0.00"; if (cost < 0.01) return `$${cost.toFixed(4)}`; return `$${cost.toFixed(2)}`; } function hexToRgb(hex: string): [number, number, number] { const normalized = hex.replace(/^#/, ""); const r = Number.parseInt(normalized.slice(0, 2), 16); const g = Number.parseInt(normalized.slice(2, 4), 16); const b = Number.parseInt(normalized.slice(4, 6), 16); return [r, g, b]; } // Computed once at load: terminal color capability does not change during a session. const TRUECOLOR = (() => { const colorterm = process.env.COLORTERM?.toLowerCase(); return colorterm === "truecolor" || colorterm === "24bit"; })(); function colorHex(theme: Theme, hex: string, text: string, fallback: ThemeColor): string { if (!TRUECOLOR) { return theme.fg(fallback, text); } const [r, g, b] = hexToRgb(hex); return `\u001b[38;2;${r};${g};${b}m${text}\u001b[39m`; } function medium(theme: Theme, text: string): string { return theme.bold(text); } function getUsageColor(percent: number | null): ThemeColor { const safePercent = percent ?? 0; if (safePercent >= 90) return "error"; if (safePercent >= 70) return "warning"; return "success"; } function getHitRateColor(rate: number): ThemeColor { if (rate >= 90) return "success"; if (rate >= 70) return "warning"; return "error"; } function stripAnsi(text: string): string { // eslint-disable-next-line no-control-regex return text.replace(/\x1b\[[0-9;]*m/g, ""); } type Segment = { key: SegmentKey; text: string }; function buildSegments(input: StatuslineRenderContext): Segment[] { const { ctx, theme, footerData, getThinkingLevel, config } = input; const segments: Array = []; const enabled = (key: SegmentKey) => config.segments[key]; // Model + thinking level const modelLabel = ctx.model?.id ?? "no-model"; const modelWithThinking = medium( theme, colorHex(theme, MODEL_HEX, `${modelLabel}(${getThinkingLevel()})`, "accent"), ); segments.push({ key: "model", text: `${colorHex(theme, MODEL_HEX, medium(theme, ICONS.model), "accent")} ${modelWithThinking}`, }); // Current directory segments.push({ key: "cwd", text: `${colorHex(theme, PATH_HEX, medium(theme, ICONS.folder), "accent")} ${colorHex( theme, PATH_HEX, medium(theme, basename(ctx.cwd) || ctx.cwd), "accent", )}`, }); // Git branch, * marks uncommitted changes const branch = footerData.getGitBranch(); segments.push( branch ? { key: "branch", text: `${colorHex(theme, BRANCH_HEX, medium(theme, ICONS.branch), "success")} ${colorHex( theme, BRANCH_HEX, medium(theme, `${branch}${input.workspace.dirty ? "*" : ""}`), "success", )}`, } : undefined, ); // Context usage (skip the per-frame token estimate when the segment is off) const usage = enabled("context") ? ctx.getContextUsage() : undefined; const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0; const tokens = usage?.tokens ?? null; const percent = usage?.percent ?? null; const usageColor = getUsageColor(percent); const percentLabel = percent === null ? "?" : `${percent.toFixed(1)}%`; const tokenLabel = tokens === null ? "?" : formatCompactTokens(tokens); const windowLabel = contextWindow > 0 ? formatCompactTokens(contextWindow) : "?"; segments.push({ key: "context", text: `${colorHex(theme, CONTEXT_HEX, medium(theme, ICONS.context), "accent")} ${colorHex( theme, CONTEXT_HEX, medium(theme, percentLabel), usageColor, )}${(percent ?? 0) >= 90 ? ` ${theme.fg("error", medium(theme, ICONS.warn))}` : ""} ${colorHex( theme, CONTEXT_HEX, `(${tokenLabel}/${windowLabel})`, "dim", )}${input.autoCompact ? ` ${colorHex(theme, TPS_HEX, ICONS.autoCompact, "dim")}` : ""}`, }); // Session cost segments.push({ key: "cost", text: medium( theme, colorHex(theme, COST_HEX, formatCost(input.usageTotals.cost), input.usageTotals.cost > 0 ? "warning" : "dim"), ), }); // Cumulative tokens: input/output, plus latest cache hit rate const usageParts = [ theme.fg("dim", `↑${formatCompactTokens(input.usageTotals.input)}`), theme.fg("dim", `↓${formatCompactTokens(input.usageTotals.output)}`), ]; if (input.cacheHitRate !== null) { usageParts.push( colorHex( theme, CACHE_HEX, medium(theme, `${ICONS.hitRate} ${input.cacheHitRate.toFixed(1)}%`), getHitRateColor(input.cacheHitRate), ), ); } segments.push({ key: "usage", text: usageParts.join(" ") }); // Output speed segments.push({ key: "tps", text: colorHex(theme, TPS_HEX, medium(theme, `${ICONS.tps} ${formatTps(input.lastTps)}`), "accent"), }); // Extension statuses from ctx.ui.setStatus(), minus noise const statuses = [...footerData.getExtensionStatuses().values()] .filter(Boolean) .filter((status) => { const normalized = stripAnsi(status).trim(); if (normalized.startsWith("MCP:")) return false; if (normalized === "Ready" || normalized === "Working") return false; return true; }); segments.push(statuses.length > 0 ? { key: "statuses", text: statuses.join(" ") } : undefined); return segments.filter((segment): segment is Segment => Boolean(segment)); } /** * Render the statusline into at most two lines. * Segments that do not fit on line 1 wrap whole to line 2; * line 2 is hard-truncated if it still overflows. */ export function renderStatusline(width: number, input: StatuslineRenderContext): string[] { const { theme } = input; const segments = buildSegments(input).filter((segment) => input.config.segments[segment.key]); const separator = colorHex(theme, SEPARATOR_HEX, " | ", "dim"); const separatorWidth = 3; const lines: string[][] = [[]]; let lineIndex = 0; let lineWidth = 0; for (const segment of segments) { const segmentWidth = visibleWidth(segment.text); const needed = lines[lineIndex].length === 0 ? segmentWidth : lineWidth + separatorWidth + segmentWidth; if (lines[lineIndex].length > 0 && needed > width && lineIndex === 0) { lineIndex = 1; lines.push([]); lineWidth = 0; } lineWidth = lines[lineIndex].length === 0 ? segmentWidth : lineWidth + separatorWidth + segmentWidth; lines[lineIndex].push(segment.text); } return lines.map((parts) => truncateToWidth(parts.join(separator), width)); }