import { renderBar, DEFAULT_STOPS, type BarStyle, type ColorStops } from "./bar.ts"; import { visibleWidth } from "@earendil-works/pi-tui"; import { composeSegments, createSegments, SEGMENT_ORDER, type SegmentId } from "./segments.ts"; import { billingMode, deriveContext, deriveEffort, deriveModel, deriveProject, isLocalEndpoint, type ContextUsage, } from "./derive.ts"; import { formatRate, formatResetCountdown, formatTime, formatWindow } from "./format.ts"; import { gitBranchSymbol, gitStatusTokens, type GitStatusState, type GitTokenKind } from "./git.ts"; import { estimateTokens, type ThroughputLevel } from "./throughput.ts"; import type { RateLimitWindow } from "./ratelimit.ts"; import type { ResetFormat, StatuslineSettings } from "./settings/schema.ts"; import type { ThemeColor } from "@earendil-works/pi-coding-agent"; /** * Single source of truth for the statusline's main line. Both the live footer * (extensions/statusline.ts) and the settings preview (settings/resolve.ts) render * through `renderMainLine`, so the preview can never drift from the real footer โ€” * only the data (live vs fixture) and the theme (real vs none) differ. */ export interface RenderTheme { fg: (color: ThemeColor, text: string) => string; getColorMode?: () => string; getFgAnsi?: (color: ThemeColor) => string; } export interface MeterSnapshot { avgInputRate?: number; avgOutputRate?: number; outputRate?: number; outputLevel?: ThroughputLevel; inputLevel?: ThroughputLevel; waitingMs?: number; activeMs?: number; elapsedMs?: number; lastTurnMs?: number; } export interface FooterSnapshot { cwd: string; model?: { id: string; provider?: string; reasoning?: boolean; baseUrl?: string }; thinkingLevel?: string; contextUsage?: ContextUsage; /** Branch name to show; only meaningful when extras.branch is on. */ gitBranch?: string | null; gitStatus?: GitStatusState; pending?: boolean; /** True for subscription-billed providers (codex or oauth). */ subscription?: boolean; turnActive?: boolean; meter?: MeterSnapshot; /** Last context char count, used to estimate the local prompt-processing rate. */ lastContextChars?: number; /** Session token/cost totals, used for the cost extra and the API ledger. */ totals?: { input: number; output: number; cost: number }; /** Active provider's subscription quota windows (shown on the main line when no provider row does). */ sessionWindows?: RateLimitWindow[]; /** True when the active provider already shows its quota on a provider-tracking row. */ activeProviderHasRow?: boolean; /** Placeholder shown for an eligible subscription provider before its windows load (e.g. "5h โ€” wk โ€”"). */ sessionPlaceholder?: string; now?: number; } const GIT_ROLES: Record = { ahead: "accent", behind: "warning", dirty: "warning", clean: "success", error: "error", }; const ICON_PRESETS: Record> = { emoji: { project: "๐Ÿ“", model: "๐Ÿค–", thinking: "๐Ÿง ", context: "๐ŸชŸ", throughput: "โšก", time: "โณ" }, unicode: { project: "โ—†", model: "โ—‡", thinking: "โ—Œ", context: "โ–ฃ", throughput: "โ†•", time: "โ—ท" }, ascii: { project: "P", model: "M", thinking: "T", context: "C", throughput: "R", time: "@" }, nerdfont: { project: "๓ฐ‰‹", model: "๓ฐง‘", thinking: "๓ฐ”Ÿ", context: "๓ฐ›", throughput: "๓ฐ“…", time: "๓ฐฅ”" }, minimal: { project: "ยท", model: "ยท", thinking: "ยท", context: "ยท", throughput: "ยท", time: "ยท" }, none: {}, custom: {}, }; function icon(settings: StatuslineSettings, name: string): string { const override = settings.icons.symbols[name]; if (override !== undefined) return override; return ICON_PRESETS[settings.icons.style][name] ?? ""; } /** Provider icon: only when explicitly configured (no default glyph), matching the live footer. */ function providerIcon(settings: StatuslineSettings, providerId?: string): string { if (!providerId) return ""; const configured = settings.icons.providers[providerId]; if (configured?.mode === "hidden") return ""; if (configured?.mode === "custom") return configured.value; return ""; } function barRole(settings: StatuslineSettings, used: number): ThemeColor { if (used * 100 >= settings.bars.critAt) return "error"; if (used * 100 >= settings.bars.warnAt) return "warning"; return "success"; } function barStyleFactory(settings: StatuslineSettings, used: number, theme: RenderTheme | undefined, stops: ColorStops): BarStyle { if (settings.bars.truecolor && theme?.getColorMode?.() === "truecolor") { return { fill: (text, rgb) => `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${text}\x1b[39m`, track: (text) => `\x1b[38;2;58;63;70m${text}\x1b[39m`, }; } const paint = (r: ThemeColor, text: string) => (theme?.fg ? theme.fg(r, text) : text); return { fill: (text) => paint(barRole(settings, used), text), track: (text) => paint("dim", text), }; } function blockBar(used: number, settings: StatuslineSettings, theme: RenderTheme | undefined, width: number, showPercent: boolean): string { const w = Math.max(2, Math.floor(Number.isFinite(width) ? width : 12)); const filled = Math.round(used * w); const paint = (r: ThemeColor, text: string) => (theme?.fg ? theme.fg(r, text) : text); const bar = paint(barRole(settings, used), `${settings.bars.capLeft || "["}${(settings.bars.fill || "โ–ˆ").repeat(filled)}${(settings.bars.empty || "โ–‘").repeat(w - filled)}${settings.bars.capRight || "]"}`); return showPercent ? `${bar} ${Math.round(used * 100)}%` : bar; } function isLineBarStyle(style: string): boolean { return style === "rounded" || style === "line"; } /** Resolved per-window display: per-provider `WindowConfiguration` overriding the global bar defaults. */ export interface ResolvedWindowDisplay { visible: boolean; label: string; showBar: boolean; showPercent: boolean; showReset: boolean; resetFormat: ResetFormat; width: number; } /** Resolve a quota window's effective display settings (per-window config wins over global bars). */ export function resolveWindowDisplay( settings: StatuslineSettings, provider: string | undefined, window: RateLimitWindow, ): ResolvedWindowDisplay { const cfg = provider ? settings.providers.records[provider]?.windows[window.key ?? ""] : undefined; const width = cfg?.width && cfg.width > 0 ? cfg.width : settings.bars.width; return { visible: cfg?.visible ?? true, label: cfg?.label ? cfg.label : window.label, showBar: cfg?.showBar ?? true, showPercent: cfg?.showPercent ?? settings.bars.showPercent, showReset: cfg?.showReset ?? true, resetFormat: cfg?.resetFormat ?? "countdown", width, }; } /** Format a reset timestamp according to the configured reset format. */ function formatReset(resetAt: number, format: ResetFormat, now: number): string { if (format === "exact-time" || format === "exact-date") { const d = new Date(resetAt); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return format === "exact-time" ? `${hh}:${mm}` : `${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")} ${hh}:${mm}`; } return formatResetCountdown(resetAt, now); } function renderSessionBar(settings: StatuslineSettings, provider: string | undefined, window: RateLimitWindow, theme: RenderTheme | undefined, now: number): string { const paint = (r: ThemeColor, text: string) => (theme?.fg ? theme.fg(r, text) : text); const display = resolveWindowDisplay(settings, provider, window); const used = Math.max(0, Math.min(1, window.used)); const bar = !display.showBar ? "" : isLineBarStyle(settings.bars.style) ? renderBar(used, display.width, barStyleFactory(settings, used, theme, DEFAULT_STOPS), DEFAULT_STOPS, display.showPercent) : blockBar(used, settings, theme, display.width, display.showPercent); const reset = display.showReset && window.resetAt !== undefined ? paint("dim", ` โ†ป ${formatReset(window.resetAt, display.resetFormat, now)}`) : ""; return `${paint("muted", `${display.label} `)}${bar}${reset}`; } /** Compact no-bar form of one window: the numbers the reader actually needs (label, %, โ†ป reset). */ function compactSessionBar(settings: StatuslineSettings, provider: string | undefined, window: RateLimitWindow, theme: RenderTheme | undefined, now: number): string { const paint = (r: ThemeColor, text: string) => (theme?.fg ? theme.fg(r, text) : text); const display = resolveWindowDisplay(settings, provider, window); const used = Math.max(0, Math.min(1, window.used)); const pct = display.showPercent ? `${Math.round(used * 100)}%` : ""; const reset = display.showReset && window.resetAt !== undefined ? paint("dim", ` โ†ป ${formatReset(window.resetAt, display.resetFormat, now)}`) : ""; const body = `${pct}${reset}`.replace(/^\s+/, ""); return body ? `${paint("muted", `${display.label} `)}${body}` : ""; } const WINDOW_SEPARATOR = " >"; /** * Render a provider's quota windows to fit `budget` visible columns. Full bars when they fit; * under pressure drop bars before numbers, then drop the soonest-resetting window first โ€” so the * weekly window (the one that actually gates usage) keeps its % and reset on narrow screens. * Shared by the main-line session segment, the provider rows, and the settings preview. */ export function renderSessionWindows( settings: StatuslineSettings, provider: string | undefined, windows: readonly RateLimitWindow[], theme: RenderTheme | undefined, now: number, budget?: number, ): string { const sep = theme?.fg ? theme.fg("dim", WINDOW_SEPARATOR) : WINDOW_SEPARATOR; const full = windows.map((window) => renderSessionBar(settings, provider, window, theme, now)).join(sep); if (budget === undefined || visibleWidth(full) <= budget) return full; const ordered = [...windows].sort((a, b) => (a.resetAt ?? -Infinity) - (b.resetAt ?? -Infinity)); const compact = () => ordered .map((window) => compactSessionBar(settings, provider, window, theme, now)) .filter((bar) => bar.trim().length > 0) .join(sep); let result = compact(); while (visibleWidth(result) > budget && ordered.length > 1) { ordered.shift(); result = compact(); } return result; } /** One provider's data for a tracking row (caller pre-filters order/enabled/health). */ export interface ProviderRowSource { provider: string; windows: readonly RateLimitWindow[]; /** Static affordance shown before a subscription provider's windows load (e.g. "5h โ€” wk โ€”"). */ placeholder?: string; } /** True when a source would produce a row: at least one visible window, or a placeholder. */ export function sourceRenders(settings: StatuslineSettings, source: ProviderRowSource): boolean { if (source.windows.some((window) => resolveWindowDisplay(settings, source.provider, window).visible)) return true; return Boolean(source.placeholder); } /** True when `provider` would show a tracking row (visible windows or placeholder), so the main line hides its quota. */ export function providerHasRow( settings: StatuslineSettings, sources: readonly ProviderRowSource[], provider: string | undefined, ): boolean { if (!settings.providers.enabled || !provider) return false; return sources.some((source) => source.provider === provider && sourceRenders(settings, source)); } /** * Render the provider-tracking rows through the same per-window logic as the main-line session * segment, so the multi-line footer and the settings preview can never drift. Pure: no I/O. */ export function renderProviderRows( settings: StatuslineSettings, sources: readonly ProviderRowSource[], theme: RenderTheme | undefined, now: number, width?: number, ): string[] { if (!settings.providers.enabled) return []; const paint = (r: ThemeColor, text: string) => (theme?.fg ? theme.fg(r, text) : text); const sep = paint("dim", WINDOW_SEPARATOR); const lines: string[] = []; for (const source of sources) { if (settings.providers.records[source.provider]?.enabled === false) continue; const prefix = paint("muted", `${source.provider} `); const visible = source.windows.filter((window) => resolveWindowDisplay(settings, source.provider, window).visible); if (visible.length) { // Width-aware: degrade in place (bars, then extra windows) instead of being chopped mid-bar. const budget = width === undefined ? undefined : Math.max(0, width - visibleWidth(prefix)); const rendered = renderSessionWindows(settings, source.provider, visible, theme, now, budget); if (rendered.trim().length > 0) lines.push(`${prefix}${rendered}`); } else if (source.placeholder) { lines.push(paint("muted", `${source.provider} ${source.placeholder}`)); } } return lines; } /** Render the single main statusline line. Pure: no I/O, no mutation. */ export function renderMainLine( settings: StatuslineSettings, snap: FooterSnapshot, width: number, theme?: RenderTheme, ): string { const paint = (role: ThemeColor, text: string) => (theme?.fg ? theme.fg(role, text) : text); const now = snap.now ?? Date.now(); const extras = settings.extras; const context = deriveContext(snap.contextUsage); const contextRole = (ctx: { percent: number; tokens: number | null }): ThemeColor => { const tokens = ctx.tokens ?? 0; if (ctx.percent >= settings.thresholds.contextCrit || tokens >= 170_000) return "error"; if (ctx.percent >= settings.thresholds.contextWarn || tokens >= 120_000) return "warning"; return "success"; }; const branch = extras.branch ? snap.gitBranch : undefined; const branchSymbol = gitBranchSymbol(settings.icons.style === "nerdfont"); const git = branch ? [ paint("accent", `${branchSymbol ? `${branchSymbol} ` : ""}${branch}`), ...(snap.gitStatus ? gitStatusTokens(snap.gitStatus).map((token) => paint(GIT_ROLES[token.kind], token.text)) : []), ].join(" ") : ""; const pending = extras.pending && snap.pending; const model = deriveModel(snap.model); const effort = deriveEffort(snap.thinkingLevel ?? "off", snap.model); const localModel = isLocalEndpoint(snap.model?.baseUrl); const mode = billingMode(localModel, snap.subscription ?? false); const needTotals = extras.cost || (mode === "api" && !snap.turnActive); const totals = needTotals ? snap.totals : undefined; const cost = extras.cost ? totals?.cost : undefined; const m = snap.meter ?? {}; const liveOutputRate = snap.turnActive && m.outputRate !== undefined ? m.outputRate : undefined; const outputRateLabel = () => paint(m.outputLevel ?? "muted", `โ†“${formatRate(liveOutputRate ?? m.avgOutputRate ?? 0)}`); const throughputIcon = icon(settings, "throughput"); // The โšก segment adapts to the billing model (see README "Throughput and time"). const throughput = (() => { if (mode === "local") { const promptRate = m.waitingMs ? estimateTokens(snap.lastContextChars ?? 0) / (m.waitingMs / 1_000) : undefined; const inputRate = promptRate ?? m.avgInputRate ?? 0; const input = paint(m.inputLevel ?? "muted", `โ†‘${formatRate(inputRate)}`); return `${paint("muted", throughputIcon)}${input} ${outputRateLabel()}${paint("muted", " t/s")}`; } if (snap.turnActive) return `${paint("muted", throughputIcon)}${outputRateLabel()}${paint("muted", " t/s")}`; if (mode === "subscription") return ""; if (!totals || (!totals.input && !totals.output)) return ""; return paint("muted", `๐Ÿงพ โ†‘${formatWindow(totals.input)} โ†“${formatWindow(totals.output)} $${totals.cost.toFixed(3)}`); })(); const activeMs = m.activeMs ?? 0; const time = activeMs > 0 || m.lastTurnMs !== undefined ? formatTime(activeMs, extras.sessionElapsed ? m.elapsedMs : undefined, extras.lastTurn ? m.lastTurnMs : undefined) : ""; const sessionWindows = snap.activeProviderHasRow ? [] : (snap.sessionWindows ?? []); const sessionProvider = snap.model?.provider; const visibleSessionWindows = sessionWindows.filter((window) => resolveWindowDisplay(settings, sessionProvider, window).visible); // Width-aware: composeSegments offers this renderer the leftover columns under width pressure, // so the quota degrades (bars, then extra windows) instead of being dropped or chopped mid-bar. const session = (budget?: number) => visibleSessionWindows.length ? renderSessionWindows(settings, sessionProvider, visibleSessionWindows, theme, now, budget) : (snap.sessionPlaceholder ?? ""); const withSpace = (name: string, value: string) => { const glyph = icon(settings, name); return glyph ? `${glyph} ${value}` : value; }; const visibility = { ...settings.segments }; const activeModel = snap.model?.provider ? settings.providers.records[snap.model.provider]?.activeModel : undefined; if (activeModel) { for (const id of SEGMENT_ORDER) { if (activeModel[id] === "on") visibility[id] = true; else if (activeModel[id] === "off") visibility[id] = false; } } const orderRank = new Map(settings.layout.segmentOrder.map((id, index) => [id, index] as const)); const segments = createSegments(visibility, { project: () => { const head = paint("muted", withSpace("project", deriveProject(snap.cwd))); const tail = `${git ? `${paint("dim", settings.separators.projectGit)}${git}` : ""}${pending ? ` ${paint("muted", "queued")}` : ""}`; return `${head}${tail}`; }, model: () => { if (!model) return ""; const provider = providerIcon(settings, snap.model?.provider); const label = provider ? `${provider}${model}` : model; return paint("muted", `${withSpace("model", label)}${cost === undefined ? "" : ` $${cost.toFixed(3)}`}`); }, effort: () => effort ? paint("muted", withSpace("thinking", effort)) : "", context: () => { if (!context) return ""; const glyph = icon(settings, "context"); const head = paint("muted", glyph ? `${glyph} ` : ""); return `${head}${paint(contextRole(context), context.label)}`; }, session, throughput: () => throughput, // formatTime already carries its own โณ glyph; no icon prefix. time: () => time ? paint("muted", time) : "", }).sort((a, b) => (orderRank.get(a.id) ?? SEGMENT_ORDER.length) - (orderRank.get(b.id) ?? SEGMENT_ORDER.length)); const inner = Math.max(0, width - Math.min(settings.separators.trailingSpacing, Math.max(0, width))); const separator = `${settings.separators.padding}${" ".repeat(settings.separators.spacingBefore)}${settings.separators.main}${" ".repeat(settings.separators.spacingAfter)}`; const line = composeSegments(segments, inner, paint("dim", separator), settings.layout.narrowPriority); const trailing = Math.min(settings.separators.trailingSpacing, Math.max(0, width)); return line + " ".repeat(trailing); }