import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui"; import { formatTokens } from "./metrics.js"; import { createPalette, type BarPalette, type PaletteRole } from "./palette.js"; import type { BarConfig, BarMetrics, BarState, SegmentId } from "./types.js"; export interface ThemeLike { fg(color: string, text: string): string; bold(text: string): string; italic(text: string): string; } export type ResponsiveMode = "gallery" | "balanced" | "focus" | "telemetry" | "safe"; const WORKING_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; const WORKING_ANIMATION_INTERVAL_MS = 120; interface FooterZones { workspace: string[]; workspaceInset: string; status?: string; menu?: string; telemetryFull: string[]; telemetrySeparator: string; requiredFull: string[]; requiredCompact: string[]; } const sanitize = (text: string): string => text .replace(/[\u0000-\u001f\u007f]/g, " ") .replace(/\s+/g, " ") .trim(); export function selectResponsiveMode(width: number): ResponsiveMode { if (width >= 132) return "gallery"; if (width >= 96) return "balanced"; if (width >= 72) return "focus"; if (width >= 56) return "telemetry"; return "safe"; } const unavailable = (theme: ThemeLike): string => theme.fg("dim", "—"); const usageValue = (metrics: BarMetrics, amount: number, theme: ThemeLike): string => metrics.usageAvailable && Number.isFinite(amount) ? formatTokens(amount) : unavailable(theme); const CONTEXT_GAUGE_WIDTH = 8; function contextGauge(metrics: BarMetrics): string { if (metrics.contextPercent === null || !Number.isFinite(metrics.contextPercent)) return "◔"; const filled = Math.round((Math.max(0, Math.min(100, metrics.contextPercent)) / 100) * CONTEXT_GAUGE_WIDTH); return `${"█".repeat(filled)}${"░".repeat(CONTEXT_GAUGE_WIDTH - filled)}`; } function contextCore(metrics: BarMetrics, theme: ThemeLike): string { const percent = metrics.contextPercent === null || !Number.isFinite(metrics.contextPercent) ? "—" : `${Math.round(metrics.contextPercent)}%`; const capacity = Number.isFinite(metrics.contextWindow) && metrics.contextWindow >= 0 ? formatTokens(metrics.contextWindow) : unavailable(theme); return `${contextGauge(metrics)} ${percent}/${capacity}`; } function contextRole(metrics: BarMetrics, config: BarConfig): PaletteRole { if (metrics.contextPercent === null || !Number.isFinite(metrics.contextPercent)) return "muted"; if (metrics.contextPercent >= config.contextDanger) return "error"; if (metrics.contextPercent >= config.contextWarning) return "warning"; return "context"; } function telemetry(metrics: BarMetrics, config: BarConfig, theme: ThemeLike, palette: BarPalette) { const input = palette.paint("input", `↑ ${usageValue(metrics, metrics.input, theme)}`); const output = palette.paint("output", `↓ ${usageValue(metrics, metrics.output, theme)}`); const hitValue = metrics.cacheHitPercent !== undefined && Number.isFinite(metrics.cacheHitPercent) ? `${Math.round(metrics.cacheHitPercent)}%` : unavailable(theme); const hit = palette.paint("cache", `↯ ${hitValue}`); const context = palette.paint(contextRole(metrics, config), contextCore(metrics, theme)); const metricGroups = [`${input} ${output}`, hit]; return { metricsFull: metricGroups, metricsCompact: metricGroups, contextFull: context, contextCompact: context, }; } function formatActivityLabel(label: string): string { return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase(); } function formatWorkingLabel(verb: string): string { return `${formatActivityLabel(verb)}…`; } function activity( state: BarState, full: boolean, palette: BarPalette, theme: ThemeLike, spinnerFrame: string, ): string { const labels = { ready: formatActivityLabel("READY"), working: formatWorkingLabel(state.workingLabel ?? "WORKING"), warning: formatActivityLabel("WARNING"), error: formatActivityLabel("ERROR"), } as const; const roles = { ready: "ready", working: "working", warning: "warning", error: "error" } as const; if (!full) return palette.paint(roles[state.activity], "●"); if (state.activity === "working") { return palette.paint("working", `${spinnerFrame} ${theme.italic(labels.working)}`); } return palette.paint(roles[state.activity], `● ${labels[state.activity]}`); } function bounded(text: string, width: number): string { return truncateToWidth(text, Math.max(1, width), ""); } function buildZones( state: BarState, config: BarConfig, theme: ThemeLike, mode: ResponsiveMode, colorEnabled: boolean, spinnerFrame: string, ): FooterZones { const palette = createPalette(theme, colorEnabled); const enabled = new Set(config.segments); const workspace: string[] = []; if (enabled.has("brand") && config.ornament !== "none") { if (mode === "gallery") workspace.push(palette.paint("brand", theme.bold("◆ BAR"))); else if (mode === "balanced") workspace.push(palette.paint("brand", theme.bold("◆"))); } if (enabled.has("activity") && mode !== "telemetry" && mode !== "safe") { workspace.push(activity(state, mode === "gallery" || mode === "balanced", palette, theme, spinnerFrame)); } if ( enabled.has("model") && state.modelId && (mode === "gallery" || mode === "balanced" || mode === "focus") ) { const modelBudget = mode === "gallery" ? 30 : mode === "balanced" ? 22 : 16; const thinking = state.thinkingLevel ? mode === "gallery" ? ` · ${state.thinkingLevel}` : mode === "balanced" ? ` · ${state.thinkingLevel.slice(0, 1)}` : "" : ""; workspace.push(`${theme.fg("text", bounded(state.modelId, modelBudget))}${theme.fg("muted", thinking)}`); } if (enabled.has("git") && state.branch && (mode === "gallery" || mode === "balanced")) { const branch = bounded(state.branch, mode === "gallery" ? 18 : 12); workspace.push(`${theme.fg("text", branch)}${state.dirty ? palette.paint("warning", " ✦") : ""}`); } let status: string | undefined; if (enabled.has("statuses") && config.showExtensionStatuses && mode === "gallery") { const statuses = state.extensionStatuses.map(sanitize).filter(Boolean).join(" "); if (statuses && visibleWidth(statuses) <= 24) status = theme.fg("muted", statuses); } const metrics = telemetry(state.metrics, config, theme, palette); const shortcut = config.shortcut.toLowerCase() === "alt+a" ? "⌥A" : sanitize(config.shortcut).toUpperCase(); const menu = enabled.has("menu") ? palette.paint("brand", shortcut) : ""; const telemetryFull: string[] = []; const telemetryCompact: string[] = []; const requiredFull: string[] = []; const requiredCompact: string[] = []; for (const id of config.segments) { if (id === "metrics") { telemetryFull.push(...metrics.metricsFull); telemetryCompact.push(...metrics.metricsCompact); requiredFull.push(...metrics.metricsFull); requiredCompact.push(...metrics.metricsCompact); } else if (id === "context") { telemetryFull.push(metrics.contextFull); telemetryCompact.push(metrics.contextCompact); requiredFull.push(metrics.contextFull); requiredCompact.push(metrics.contextCompact); } else if (id === "menu" && menu) { telemetryFull.push(menu); telemetryCompact.push(menu); } } return { workspace, workspaceInset: " ", ...(status ? { status } : {}), ...(menu ? { menu } : {}), telemetryFull, telemetrySeparator: " ", requiredFull, requiredCompact, }; } function joinGroups(groups: string[], separator: string): string { return groups.filter(Boolean).join(separator); } function renderWorkspace(workspace: string[], inset: string, separator: string): string { const rendered = joinGroups(workspace, separator); return rendered ? `${inset}${rendered}` : ""; } function renderGallery(zones: FooterZones, width: number): string { const right = joinGroups(zones.telemetryFull, zones.telemetrySeparator); let left = renderWorkspace(zones.workspace, zones.workspaceInset, " "); if (zones.status) { const withStatus = renderWorkspace([...zones.workspace, zones.status], zones.workspaceInset, " "); if (width - visibleWidth(withStatus) - visibleWidth(right) >= 2) left = withStatus; } const padding = width - visibleWidth(left) - visibleWidth(right); if (padding >= 2) return `${left}${" ".repeat(padding)}${right}`; return ""; } function renderWithOptionalWorkspace(zones: FooterZones, width: number, separator: string): string { let workspace = [...zones.workspace]; const required = zones.requiredCompact; while ( workspace.length > 0 && visibleWidth( joinGroups([renderWorkspace(workspace, zones.workspaceInset, separator), ...required], separator), ) > width ) { workspace.pop(); } let groups = [renderWorkspace(workspace, zones.workspaceInset, separator), ...required]; if (zones.menu && visibleWidth(joinGroups([...groups, zones.menu], separator)) <= width) { groups = [...groups, zones.menu]; } return joinGroups(groups, separator); } function renderBalanced(zones: FooterZones, width: number, theme: ThemeLike): string { return renderWithOptionalWorkspace(zones, width, theme.fg("borderMuted", " │ ")); } function renderFocus(zones: FooterZones, width: number, theme: ThemeLike): string { return renderWithOptionalWorkspace(zones, width, theme.fg("borderMuted", " · ")); } function renderTelemetry(zones: FooterZones): string { return joinGroups(zones.requiredCompact, " "); } export function renderFooterLine( state: BarState, config: BarConfig, theme: ThemeLike, width: number, colorEnabled = true, spinnerFrame: string = WORKING_SPINNER_FRAMES[0], ): string { if (width <= 0) return ""; const mode = selectResponsiveMode(width); const zones = buildZones(state, config, theme, mode, colorEnabled, spinnerFrame); let line: string; if (mode === "gallery") { line = renderGallery(zones, width) || renderBalanced(buildZones(state, config, theme, "balanced", colorEnabled, spinnerFrame), width, theme); } else if (mode === "balanced") line = renderBalanced(zones, width, theme); else if (mode === "focus") line = renderFocus(zones, width, theme); else line = renderTelemetry(zones); return truncateToWidth(line, width, ""); } export interface FooterComponentOptions { getState(): BarState; getConfig(): BarConfig; colorEnabled?: boolean; requestRender(): void; onBranchChange(callback: () => void): () => void; theme: ThemeLike; } export function createFooterComponent(options: FooterComponentOptions): Component & { dispose(): void } { let disposed = false; let frameIndex = 0; let animationTimer: ReturnType | undefined; const unsubscribe = options.onBranchChange(options.requestRender); const stopAnimation = (): void => { if (animationTimer) { clearInterval(animationTimer); animationTimer = undefined; } frameIndex = 0; }; const syncAnimation = (visible: boolean): void => { if (disposed || !visible) { stopAnimation(); return; } if (animationTimer) return; animationTimer = setInterval(() => { if (disposed) return; frameIndex = (frameIndex + 1) % WORKING_SPINNER_FRAMES.length; options.requestRender(); }, WORKING_ANIMATION_INTERVAL_MS); }; return { render(width) { const state = options.getState(); const colorEnabled = options.colorEnabled ?? true; const spinnerFrame = WORKING_SPINNER_FRAMES[frameIndex] ?? WORKING_SPINNER_FRAMES[0]; const line = renderFooterLine( state, options.getConfig(), options.theme, width, colorEnabled, spinnerFrame, ); const fullActivity = activity( state, true, createPalette(options.theme, colorEnabled), options.theme, spinnerFrame, ); syncAnimation(state.activity === "working" && line.includes(fullActivity)); return [line]; }, invalidate() {}, dispose() { if (disposed) return; disposed = true; stopAnimation(); unsubscribe(); }, }; }