/** * usage-overlay.tsx — Ink UsageOverlayView component for token usage stats. * * Replaces the neo-blessed UsageOverlay class with a declarative React * component. The parent manages data loading, grouping state, and selected * index; this component handles rendering and keybind dispatch. * * Features: * - Overall usage summary (input, output, cache, reasoning, total, cost) * - Grouped item list with key, token counts, cost, steps * - Tab to cycle grouping (task_id → quest_id → model → provider) * - Up/Down to navigate the group list * - Escape/U to close * * Usage: * setShowUsage(false)} * onCycleGrouping={() => cycleGrouping()} * onSelectIndex={(idx) => setSelectedIdx(idx)} * /> */ import React from "react"; import { Box, Text, useInput } from "ink"; import { Modal } from "./modal"; import type { UsageTotals, GroupableField } from "../lib/token-usage"; // --------------------------------------------------------------------------- // Format Helpers (exported for testing) // --------------------------------------------------------------------------- /** * Format a token count with k/M suffixes for compact display. */ export function formatTokenCount(n: number): string { if (n === 0) return "0"; if (n < 1000) return String(n); if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; return `${(n / 1_000_000).toFixed(2)}M`; } /** * Format a cost value as a dollar string. */ export function formatCost(cost: number): string { if (cost === 0) return "$0.00"; if (cost < 0.01) return `$${cost.toFixed(4)}`; return `$${cost.toFixed(2)}`; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** The grouping fields available for cycling */ const GROUPING_FIELDS: GroupableField[] = [ "task_id", "quest_id", "model", "provider", ]; /** Human-readable labels for grouping fields */ const GROUPING_LABELS: Record = { task_id: "Task", quest_id: "Quest", model: "Model", provider: "Provider", harness: "Harness", }; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface UsageOverlayViewProps { /** Overall usage totals, or null if no data. */ overall: UsageTotals | null; /** Grouped usage data, sorted by total_tokens descending. */ groups: Array<{ key: string; totals: UsageTotals }>; /** Current grouping field. */ groupField: GroupableField; /** Currently selected index in the group list. */ selectedIndex: number; /** Called when the overlay should be closed (Escape or U). */ onClose: () => void; /** Called when Tab is pressed to cycle grouping. */ onCycleGrouping: () => void; /** Called when Up/Down changes the selected index. */ onSelectIndex: (index: number) => void; } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function OverallSummary({ overall }: { overall: UsageTotals }): React.ReactElement { return ( Overall Usage: Input: {formatTokenCount(overall.input_tokens)} Output: {formatTokenCount(overall.output_tokens)} {overall.cache_read > 0 && ( <> Cache: {formatTokenCount(overall.cache_read)} )} {overall.reasoning_tokens > 0 && ( <> Reasoning: {formatTokenCount(overall.reasoning_tokens)} )} {overall.reasoning_tokens === 0 && } Total: {formatTokenCount(overall.total_tokens)} {overall.total_cost > 0 && ( <> Cost: {formatCost(overall.total_cost)} )} Steps: {overall.record_count} ); } function GroupItem({ item, isSelected, }: { item: { key: string; totals: UsageTotals }; isSelected: boolean; }): React.ReactElement { const maxKeyLen = 24; const displayKey = item.key.length > maxKeyLen ? item.key.slice(0, maxKeyLen - 1) + "…" : item.key.padEnd(maxKeyLen); const input = `In: ${formatTokenCount(item.totals.input_tokens)}`.padEnd(12); const output = `Out: ${formatTokenCount(item.totals.output_tokens)}`.padEnd(13); const cost = item.totals.total_cost > 0 ? formatCost(item.totals.total_cost) : ""; const steps = `${item.totals.record_count}st`; return ( {isSelected ? "▸ " : " "} {displayKey} {input} {output} {cost} {steps} ); } // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- /** * UsageOverlayView — a declarative token usage overlay component. */ export function UsageOverlayView({ overall, groups, groupField, selectedIndex, onClose, onCycleGrouping, onSelectIndex, }: UsageOverlayViewProps): React.ReactElement { useInput((input, key) => { if (key.escape || input === "u" || input === "U") { onClose(); return; } if (key.tab) { onCycleGrouping(); return; } if ((key.downArrow || input === "j") && groups.length > 0) { const next = Math.min(selectedIndex + 1, groups.length - 1); onSelectIndex(next); return; } if ((key.upArrow || input === "k") && groups.length > 0) { const prev = Math.max(selectedIndex - 1, 0); onSelectIndex(prev); return; } }); // Next grouping label for footer const nextIdx = (GROUPING_FIELDS.indexOf(groupField) + 1) % GROUPING_FIELDS.length; const nextLabel = GROUPING_LABELS[GROUPING_FIELDS[nextIdx]]; const titleStr = overall ? `Token Usage Total: ${formatCost(overall.total_cost)}` : "Token Usage"; return ( Tab group by {nextLabel} Esc/U close | Grouped by: {GROUPING_LABELS[groupField]} } > {/* Overall summary */} {overall ? ( ) : ( No token usage data found. Usage data is recorded when agents run and produce step_finish events. )} {/* Group list */} {groups.length > 0 && ( By {GROUPING_LABELS[groupField]}: {groups.map((item, i) => ( ))} )} {groups.length === 0 && overall && ( No usage data to group. )} ); }