/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 * @plan PLAN-20250909-TOKTRACK.P06 * @plan PLAN-20250909-TOKTRACK.P16 * @requirement REQ-INT-001.1 */ import React, { useEffect, useState, useRef } from 'react'; import { Box, Text } from 'ink'; import { Colors, SemanticColors } from '../colors.js'; import { shortenPath, tildeifyPath, tokenLimit, } from '@vybestack/llxprt-code-core'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import process from 'node:process'; import v8 from 'node:v8'; import { useRuntimeApi } from '../contexts/RuntimeContext.js'; import { DebugProfiler } from './DebugProfiler.js'; import { useResponsive } from '../hooks/useResponsive.js'; import { truncateMiddle } from '../utils/responsive.js'; import { ThemedGradient } from './ThemedGradient.js'; const DEFAULT_HEAP_LIMIT = 4.8 * 1024 * 1024 * 1024; function isBunRuntime(): boolean { return ( typeof process.versions.bun === 'string' && process.versions.bun.length > 0 ); } function getHeapSizeLimit(): number { const rawHeapLimit = v8.getHeapStatistics().heap_size_limit; return rawHeapLimit > 0 ? rawHeapLimit : DEFAULT_HEAP_LIMIT; } function areFooterStablePropsEqual( prevProps: FooterProps, nextProps: FooterProps, ): boolean { const stableProps: Array = [ 'model', 'targetDir', 'branchName', 'debugMode', 'debugMessage', 'errorCount', 'showErrorDetails', 'showMemoryUsage', 'historyTokenCount', 'isPaidMode', 'nightly', 'vimMode', 'contextLimit', 'isTrustedFolder', 'hideCWD', 'hideSandboxStatus', 'hideModelInfo', 'themeName', ]; return stableProps.every((prop) => prevProps[prop] === nextProps[prop]); } interface FooterProps { model: string; targetDir: string; branchName?: string; debugMode: boolean; debugMessage: string; errorCount: number; showErrorDetails: boolean; showMemoryUsage?: boolean; historyTokenCount: number; isPaidMode?: boolean; nightly: boolean; vimMode?: string; contextLimit?: number; isTrustedFolder?: boolean; // Token tracking metrics tokensPerMinute?: number; throttleWaitTimeMs?: number; sessionTokenTotal?: number; // Theme tracking for memo invalidation themeName?: string; // Footer visibility settings hideCWD?: boolean; hideSandboxStatus?: boolean; hideModelInfo?: boolean; } // Responsive Memory Usage Display - Memoized to prevent re-renders interface ResponsiveMemoryDisplayProps { compact: boolean; detailed: boolean; } function formatGigabytes(bytes: number): string { return (bytes / 1024 ** 3).toFixed(1); } function formatMemoryUsage( usage: NodeJS.MemoryUsage, compact: boolean, detailed: boolean, ): string { const heapUsed = formatGigabytes(usage.heapUsed); const rssValue = formatGigabytes(usage.rss); const heapSuffix = compact ? 'G' : 'GB'; const rss = `RSS: ${rssValue}${compact ? 'G' : 'GB'}`; const heap = isBunRuntime() ? `Heap: ${heapUsed}${heapSuffix}` : `Heap: ${heapUsed}${heapSuffix}/${formatGigabytes(getHeapSizeLimit())}${heapSuffix}`; if (compact || !detailed) { return `${heap} ${rss}`; } return `${heap} External: ${formatGigabytes(usage.external)}GB ArrayBuffers: ${formatGigabytes(usage.arrayBuffers)}GB ${rss}`; } export const ResponsiveMemoryDisplay = React.memo( ({ compact, detailed }: ResponsiveMemoryDisplayProps) => { // One snapshot for both initial states: separate lazy initialisers would // each invoke the syscall and could disagree with each other. A ref rather // than state, because this value never changes and must never re-render. const initialUsageRef = useRef(null); initialUsageRef.current ??= process.memoryUsage(); const initialUsage = initialUsageRef.current; const [memoryUsage, setMemoryUsage] = useState(() => formatMemoryUsage(initialUsage, compact, detailed), ); const [memoryUsageColor, setMemoryUsageColor] = useState(() => initialUsage.rss >= 2 * 1024 ** 3 ? SemanticColors.status.error : SemanticColors.text.secondary, ); useEffect(() => { const updateMemory = () => { const usage = process.memoryUsage(); setMemoryUsage(formatMemoryUsage(usage, compact, detailed)); setMemoryUsageColor( usage.rss >= 2 * 1024 ** 3 ? SemanticColors.status.error : SemanticColors.text.secondary, ); }; const intervalId = setInterval(updateMemory, 2000); return () => clearInterval(intervalId); }, [compact, detailed]); return {memoryUsage}; }, ); ResponsiveMemoryDisplay.displayName = 'ResponsiveMemoryDisplay'; // Responsive Context Usage Display - Memoized to prevent re-renders interface ResponsiveContextDisplayProps { historyTokenCount: number; model: string; contextLimit?: number; compact: boolean; detailed: boolean; } const ResponsiveContextDisplay = React.memo( ({ historyTokenCount, model, contextLimit, compact, detailed, }: ResponsiveContextDisplayProps) => { const limit = tokenLimit(model, contextLimit); const percentage = historyTokenCount / limit; const remainingPercentage = (1 - percentage) * 100; // Use semantic colors based on how much context is left let color: string; if (remainingPercentage < 10) { color = SemanticColors.status.error; } else if (remainingPercentage < 25) { color = SemanticColors.status.warning; } else { color = SemanticColors.text.secondary; } let displayText: string; if (detailed) { displayText = `Context: ${historyTokenCount.toLocaleString()}/${limit.toLocaleString()} tokens`; } else if (compact) { displayText = `Ctx: ${(historyTokenCount / 1000).toFixed(1)}k/${(limit / 1000).toFixed(0)}k`; } else { displayText = `Context: ${(historyTokenCount / 1000).toFixed(1)}k/${(limit / 1000).toFixed(0)}k`; } return {displayText}; }, ); ResponsiveContextDisplay.displayName = 'ResponsiveContextDisplay'; // Debounced TPM Display - Updates less frequently to reduce flicker interface DebouncedTPMDisplayProps { tokensPerMinute?: number; themeName?: string; } const DebouncedTPMDisplay = React.memo( ({ tokensPerMinute }: DebouncedTPMDisplayProps) => { const [displayTPM, setDisplayTPM] = useState( tokensPerMinute, ); useEffect(() => { // Debounce TPM updates to reduce flicker const timeoutId = setTimeout(() => { setDisplayTPM(tokensPerMinute); }, 500); // 500ms debounce return () => clearTimeout(timeoutId); }, [tokensPerMinute]); if (displayTPM === undefined) return null; return ( {displayTPM < 1000 ? `TPM: ${displayTPM.toFixed(2)}` : `TPM: ${(displayTPM / 1000).toFixed(2)}k`} ); }, ); DebouncedTPMDisplay.displayName = 'DebouncedTPMDisplay'; // Debounced Wait Time Display interface DebouncedWaitDisplayProps { throttleWaitTimeMs?: number; themeName?: string; } const DebouncedWaitDisplay = React.memo( ({ throttleWaitTimeMs }: DebouncedWaitDisplayProps) => { const [displayWait, setDisplayWait] = useState( throttleWaitTimeMs, ); useEffect(() => { // Debounce wait time updates const timeoutId = setTimeout(() => { setDisplayWait(throttleWaitTimeMs); }, 300); // 300ms debounce return () => clearTimeout(timeoutId); }, [throttleWaitTimeMs]); if (displayWait === undefined) return null; let waitText: string; if (displayWait < 1000) { waitText = `Wait: ${displayWait}ms`; } else if (displayWait < 60000) { waitText = `Wait: ${(displayWait / 1000).toFixed(1)}s`; } else { waitText = `Wait: ${(displayWait / 60000).toFixed(1)}m`; } return {waitText}; }, ); DebouncedWaitDisplay.displayName = 'DebouncedWaitDisplay'; // Responsive Timestamp Display - Isolated component for clock updates const ResponsiveTimestamp = React.memo(() => { // Initialize with immediate value to avoid empty render in tests const initialTime = new Date().toTimeString().slice(0, 8); // HH:MM:SS const [time, setTime] = useState(initialTime); useEffect(() => { const updateTime = () => { const now = new Date(); setTime(now.toTimeString().slice(0, 8)); // HH:MM:SS }; const intervalId = setInterval(updateTime, 1000); // Don't call updateTime immediately since we have initial value return () => clearInterval(intervalId); }, []); return {time}; }); ResponsiveTimestamp.displayName = 'ResponsiveTimestamp'; // Branch display sub-component interface BranchDisplayProps { branchName: string; nightly: boolean; maxBranchLength: number; } const BranchDisplay = React.memo( ({ branchName, nightly, maxBranchLength }: BranchDisplayProps) => { const displayBranch = branchName.length > maxBranchLength ? truncateMiddle(branchName, maxBranchLength) : branchName; if (nightly) { return ( ({displayBranch}*) ); } return ({displayBranch}*); }, ); BranchDisplay.displayName = 'BranchDisplay'; // Model name sub-component. // // The profile-qualified identity (e.g. `profileName:modelName` or // `lb:::` for load balancers) is computed reactively by // useModelRuntimeSync and arrives here via the `model` prop. Rendering the // prop directly — rather than reading runtime stats imperatively — keeps the // footer in sync when a load-balancer selects a new sub-profile mid-session, // which previously left a stale identity because Footer is memoised on `model`. interface ModelNameDisplayProps { model: string; showModelName: boolean; } const ModelNameDisplay = React.memo( ({ model, showModelName }: ModelNameDisplayProps) => { if (!showModelName) return null; return {model}; }, ); ModelNameDisplay.displayName = 'ModelNameDisplay'; // Paid/free mode sub-component interface PaidModeDisplayProps { isPaidMode: boolean | undefined; showModelName: boolean; runtime: ReturnType; } const PaidModeDisplay = React.memo( ({ isPaidMode, showModelName, runtime }: PaidModeDisplayProps) => { if (isPaidMode === undefined) return null; const status = runtime.getActiveProviderStatus(); if (status.providerName !== 'gemini') return null; return ( <> {showModelName && ( | )} {isPaidMode ? 'paid mode' : 'free mode'} ); }, ); PaidModeDisplay.displayName = 'PaidModeDisplay'; // Sandbox status sub-component interface SandboxStatusDisplayProps { hideSandboxStatus: boolean; isCompact: boolean; } const SandboxStatusDisplay = React.memo( ({ hideSandboxStatus, isCompact }: SandboxStatusDisplayProps) => { if (isCompact || hideSandboxStatus) return null; let sandboxStatus: React.ReactNode; if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { sandboxStatus = ( [{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}] ); } else if (process.env.SANDBOX === 'sandbox-exec') { sandboxStatus = ( [macOS Seatbelt{' '} ({process.env.SEATBELT_PROFILE}) ] ); } else { sandboxStatus = ( [no sandbox{' '} (see /docs)] ); } return {sandboxStatus}; }, ); SandboxStatusDisplay.displayName = 'SandboxStatusDisplay'; // Right side: Memory | Context | TPM | Wait Time | Time interface FooterMetricsRowProps { hideModelInfo: boolean; showMemoryUsage?: boolean; isCompact: boolean; isDetailed: boolean; historyTokenCount: number; model: string; contextLimit?: number; tokensPerMinute?: number; throttleWaitTimeMs?: number; themeName?: string; showTimestamp: boolean; } const FooterMetricsRow = React.memo( ({ hideModelInfo, showMemoryUsage, isCompact, isDetailed, historyTokenCount, model, contextLimit, tokensPerMinute, throttleWaitTimeMs, themeName, showTimestamp, }: FooterMetricsRowProps) => { if (hideModelInfo) return null; return ( {(showMemoryUsage ?? false) && ( <> | )} {tokensPerMinute !== undefined && ( <> | )} {throttleWaitTimeMs !== undefined && ( <> | )} {showTimestamp && ( <> | )} ); }, ); FooterMetricsRow.displayName = 'FooterMetricsRow'; // Footer first line: Branch (left) | Memory | Context | Time (right) interface FooterFirstLineProps { branchName?: string; nightly: boolean; isTrustedFolder?: boolean; debugMode: boolean; debugMessage: string; vimMode?: string; maxBranchLength: number; hideModelInfo: boolean; showMemoryUsage?: boolean; isCompact: boolean; isDetailed: boolean; historyTokenCount: number; model: string; contextLimit?: number; tokensPerMinute?: number; throttleWaitTimeMs?: number; themeName?: string; showTimestamp: boolean; } const FooterFirstLine = React.memo((props: FooterFirstLineProps) => { const { branchName, nightly, isTrustedFolder, debugMode, debugMessage, vimMode, maxBranchLength, hideModelInfo, showMemoryUsage, isCompact, isDetailed, historyTokenCount, model, contextLimit, tokensPerMinute, throttleWaitTimeMs, themeName, showTimestamp, } = props; return ( {branchName && ( )} {isTrustedFolder === false && ( (untrusted) )} {debugMode && ( <> {' ' + (debugMessage || '--debug')} )} {vimMode && ( [{vimMode}] )} ); }); FooterFirstLine.displayName = 'FooterFirstLine'; // Footer second line: Path (left) | Model | Session Tokens (right) interface FooterSecondLineProps { hideCWD: boolean; nightly: boolean; targetDir: string; isCompact: boolean; hideSandboxStatus: boolean; hideModelInfo: boolean; showModelName: boolean; model: string; runtime: ReturnType; isPaidMode: boolean | undefined; sessionTokenTotal: number | undefined; showErrorDetails: boolean; errorCount: number; } const FooterSecondLine = React.memo((props: FooterSecondLineProps) => { const { hideCWD, nightly, targetDir, isCompact, hideSandboxStatus, hideModelInfo, showModelName, model, runtime, isPaidMode, sessionTokenTotal, showErrorDetails, errorCount, } = props; return ( {!hideCWD && ( {nightly ? ( {shortenPath(tildeifyPath(targetDir), isCompact ? 30 : 70)} ) : ( {shortenPath(tildeifyPath(targetDir), isCompact ? 30 : 70)} )} )} {!hideModelInfo && ( {sessionTokenTotal !== undefined && ( <> | Tokens: {sessionTokenTotal.toLocaleString()} )} {!showErrorDetails && errorCount > 0 && ( <> | )} )} ); }); FooterSecondLine.displayName = 'FooterSecondLine'; function getMaxBranchLength(breakpoint: string): number { if (breakpoint === 'NARROW') return 15; if (breakpoint === 'STANDARD') return 35; return 100; } export const Footer = React.memo( ({ model, targetDir, branchName, debugMode, debugMessage, errorCount, showErrorDetails, showMemoryUsage, historyTokenCount, isPaidMode, nightly, vimMode, contextLimit, isTrustedFolder, tokensPerMinute, throttleWaitTimeMs, sessionTokenTotal, themeName, hideCWD = false, hideSandboxStatus = false, hideModelInfo = false, }: FooterProps) => { const { breakpoint } = useResponsive(); const runtime = useRuntimeApi(); const showTimestamp = breakpoint === 'WIDE'; const showModelName = breakpoint !== 'NARROW'; const isCompact = breakpoint === 'NARROW'; const isDetailed = breakpoint === 'WIDE'; const maxBranchLength = getMaxBranchLength(breakpoint); return ( ); }, areFooterStablePropsEqual, ); Footer.displayName = 'Footer';