{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "run-ledger",
  "title": "Run Ledger",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "What an agent run cost, step by step: sortable time, tokens and money with each row's share, a total, the most expensive step marked, and an optional budget meter that warns in words.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/run-ledger.tsx",
      "content": "// Ballmac UI: Run Ledger. https://ui.ballmac.com/components/run-ledger\n\"use client\"\n\nimport * as React from \"react\"\nimport { AlertTriangle, ArrowDown, ArrowUp, ChevronsUpDown, Info } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages, type Msg } from \"@/lib/ballmac/i18n\"\n\ntype LedgerRow = {\n  id: string\n  /** The step, agent or tool the cost belongs to. */\n  label: string\n  /** What kind of work it was, for example \"Model\" or \"Tool\". */\n  kind?: string\n  /** Tokens sent in. */\n  tokensIn?: number\n  /** Tokens received. */\n  tokensOut?: number\n  /** Time taken, in milliseconds. */\n  ms: number\n  /** Cost, in the ledger's currency. */\n  cost: number\n}\n\ntype SortKey = \"cost\" | \"time\" | \"tokens\" | \"label\"\n\n/** 850 → \"850 ms\", 4200 → \"4.2 s\", 125000 → \"2 min 5 s\". */\nfunction formatDuration(ms: number) {\n  if (!Number.isFinite(ms) || ms < 0) return \"0 ms\"\n  if (ms < 1000) return `${Math.round(ms)} ms`\n  const s = ms / 1000\n  if (s < 60) return `${+s.toFixed(1)} s`\n  const m = Math.floor(s / 60)\n  const r = Math.round(s - m * 60)\n  return r === 60 ? `${m + 1} min` : r === 0 ? `${m} min` : `${m} min ${r} s`\n}\n\nfunction formatTokens(n: number) {\n  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(2)}M`\n  if (n >= 10_000) return `${+(n / 1000).toFixed(1)}k`\n  return n.toLocaleString(\"en-US\")\n}\n\ntype RunLedgerProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** One row per step, agent or tool. */\n  rows: LedgerRow[]\n  /** Spending limit for the run, in the same currency. Adds a budget bar with a warning. */\n  budget?: number\n  /** Percent of the budget at which the warning starts. */\n  warnAt?: number\n  /** ISO currency code. */\n  currency?: string\n  /** Locale for money. Fixed by default so the server and browser agree. */\n  locale?: string\n  /** Heading. */\n  title?: string\n  /** Which column is sorted first. */\n  defaultSort?: SortKey\n}\n\nfunction money(value: number, currency: string, locale: string) {\n  const digits = Math.abs(value) > 0 && Math.abs(value) < 1 ? 4 : 2\n  return new Intl.NumberFormat(locale, { style: \"currency\", currency, minimumFractionDigits: 2, maximumFractionDigits: digits }).format(value)\n}\n\nfunction budgetText(msg: Msg, state: \"ok\" | \"warning\" | \"over\", left: string, over: string) {\n  if (state === \"over\") return msg(\"run-ledger.over\", \"Over budget by {amount}\", { amount: over })\n  if (state === \"warning\") return msg(\"run-ledger.nearing\", \"Nearing the budget, {amount} left\", { amount: left })\n  return msg(\"run-ledger.left\", \"{amount} left in the budget\", { amount: left })\n}\n\n/**\n * What a run cost, step by step: time, tokens and money with the share each row takes, sortable columns, a total, and an optional budget that warns in words.\n * The bars are decoration; every number is also text.\n */\nfunction RunLedger({ rows, budget, warnAt = 80, currency = \"USD\", locale = \"en-US\", title, defaultSort = \"cost\", className, ...props }: RunLedgerProps) {\n  const msg = useMessages()\n  title ??= msg(\"run-ledger.title\", \"Run ledger\")\n  const [sort, setSort] = React.useState<{ key: SortKey; dir: \"asc\" | \"desc\" }>({ key: defaultSort, dir: defaultSort === \"label\" ? \"asc\" : \"desc\" })\n\n  const total = React.useMemo(\n    () => rows.reduce((t, r) => ({ cost: t.cost + r.cost, ms: t.ms + r.ms, tokens: t.tokens + (r.tokensIn ?? 0) + (r.tokensOut ?? 0) }), { cost: 0, ms: 0, tokens: 0 }),\n    [rows]\n  )\n  const maxCost = Math.max(...rows.map((r) => r.cost), 0)\n  const sorted = React.useMemo(() => {\n    const value = (r: LedgerRow) => (sort.key === \"cost\" ? r.cost : sort.key === \"time\" ? r.ms : sort.key === \"tokens\" ? (r.tokensIn ?? 0) + (r.tokensOut ?? 0) : r.label.toLowerCase())\n    const out = [...rows].sort((a, b) => (value(a) < value(b) ? -1 : value(a) > value(b) ? 1 : 0))\n    return sort.dir === \"desc\" ? out.reverse() : out\n  }, [rows, sort])\n\n  const percent = budget && budget > 0 ? (total.cost / budget) * 100 : 0\n  const state = budget ? (percent >= 100 ? \"over\" : percent >= warnAt ? \"warning\" : \"ok\") : \"ok\"\n\n  function header(key: SortKey, text: string, align: \"start\" | \"end\" = \"end\") {\n    const active = sort.key === key\n    const Icon = !active ? ChevronsUpDown : sort.dir === \"asc\" ? ArrowUp : ArrowDown\n    return (\n      <th scope=\"col\" aria-sort={active ? (sort.dir === \"asc\" ? \"ascending\" : \"descending\") : \"none\"} className={cn(\"px-3 py-2 font-medium\", align === \"end\" ? \"text-end\" : \"text-start\")}>\n        <button\n          type=\"button\"\n          onClick={() => setSort((s) => (s.key === key ? { key, dir: s.dir === \"asc\" ? \"desc\" : \"asc\" } : { key, dir: key === \"label\" ? \"asc\" : \"desc\" }))}\n          className={cn(\"inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\", active ? \"text-foreground\" : \"text-muted-foreground\")}\n        >\n          {text}\n          <Icon aria-hidden=\"true\" className=\"size-3\" />\n        </button>\n      </th>\n    )\n  }\n\n  return (\n    <div data-slot=\"run-ledger\" data-budget={state} className={cn(\"grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5\", className)} {...props}>\n      <div className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n        <p className=\"text-sm font-medium text-foreground\">{title}</p>\n        <p className=\"text-xs text-muted-foreground tabular-nums\">\n          {msg(\"run-ledger.summary\", \"{count} steps, {time}, {tokens} tokens\", { count: rows.length, time: formatDuration(total.ms), tokens: formatTokens(total.tokens) })}\n        </p>\n      </div>\n\n      {budget !== undefined && budget > 0 && (\n        <div className=\"grid gap-2\">\n          <div\n            role=\"meter\"\n            aria-label={msg(\"run-ledger.budget\", \"Budget\")}\n            aria-valuemin={0}\n            aria-valuemax={budget}\n            aria-valuenow={Math.min(budget, total.cost)}\n            aria-valuetext={`${money(total.cost, currency, locale)} of ${money(budget, currency, locale)}. ${budgetText(msg, state, money(Math.max(0, budget - total.cost), currency, locale), money(total.cost - budget, currency, locale))}`}\n            className=\"h-2.5 overflow-hidden rounded-full bg-muted\"\n          >\n            <div className={cn(\"h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none\", state === \"over\" ? \"bg-destructive\" : state === \"warning\" ? \"bg-chart-3\" : \"bg-foreground\")} style={{ width: `${Math.min(100, percent)}%` }} />\n          </div>\n          <p role={state === \"ok\" ? undefined : \"status\"} className=\"flex items-center gap-1.5 text-[13px]\">\n            {state === \"ok\" ? <Info aria-hidden=\"true\" className=\"size-3.5 text-muted-foreground\" /> : <AlertTriangle aria-hidden=\"true\" className={cn(\"size-3.5\", state === \"over\" ? \"text-destructive\" : \"text-chart-3\")} />}\n            <span className=\"tabular-nums\">{money(total.cost, currency, locale)}</span>\n            <span className=\"text-muted-foreground\">/ {money(budget, currency, locale)}</span>\n            <span className=\"ms-auto\">{budgetText(msg, state, money(Math.max(0, budget - total.cost), currency, locale), money(total.cost - budget, currency, locale))}</span>\n          </p>\n        </div>\n      )}\n\n      <div className=\"overflow-x-auto rounded-lg border\">\n        <table className=\"w-full min-w-[34rem] border-collapse text-[13px]\">\n          <caption className=\"sr-only\">{title}</caption>\n          <thead className=\"bg-muted/40\">\n            <tr>\n              {header(\"label\", msg(\"run-ledger.step\", \"Step\"), \"start\")}\n              {header(\"time\", msg(\"run-ledger.time\", \"Time\"))}\n              {header(\"tokens\", msg(\"run-ledger.tokens\", \"Tokens\"))}\n              {header(\"cost\", msg(\"run-ledger.cost\", \"Cost\"))}\n            </tr>\n          </thead>\n          <tbody className=\"divide-y\">\n            {sorted.map((r) => {\n              const share = total.cost > 0 ? (r.cost / total.cost) * 100 : 0\n              const top = r.cost === maxCost && maxCost > 0 && rows.length > 1\n              return (\n                <tr key={r.id} data-top={top || undefined}>\n                  <th scope=\"row\" className=\"px-3 py-2.5 text-start font-normal\">\n                    <span className=\"flex items-center gap-2\">\n                      <span className=\"truncate font-medium text-foreground\">{r.label}</span>\n                      {r.kind && <span className=\"shrink-0 rounded-full border px-1.5 py-px text-[11px] text-muted-foreground\">{r.kind}</span>}\n                      {top && <span className=\"shrink-0 rounded-full bg-foreground px-1.5 py-px text-[11px] font-medium text-background\">{msg(\"run-ledger.top\", \"Most expensive\")}</span>}\n                    </span>\n                    <span aria-hidden=\"true\" className=\"mt-1.5 block h-1 overflow-hidden rounded-full bg-muted\">\n                      <span className=\"block h-full rounded-full bg-chart-1\" style={{ width: `${maxCost > 0 ? (r.cost / maxCost) * 100 : 0}%` }} />\n                    </span>\n                  </th>\n                  <td className=\"px-3 py-2.5 text-end text-muted-foreground tabular-nums\">{formatDuration(r.ms)}</td>\n                  <td className=\"px-3 py-2.5 text-end text-muted-foreground tabular-nums\">{r.tokensIn !== undefined || r.tokensOut !== undefined ? formatTokens((r.tokensIn ?? 0) + (r.tokensOut ?? 0)) : \"—\"}</td>\n                  <td className=\"px-3 py-2.5 text-end tabular-nums\">\n                    <span className=\"font-medium text-foreground\">{money(r.cost, currency, locale)}</span>\n                    <span className=\"ms-1.5 text-xs text-muted-foreground\">{Math.round(share)}%</span>\n                  </td>\n                </tr>\n              )\n            })}\n          </tbody>\n          <tfoot className=\"border-t bg-muted/30 font-medium\">\n            <tr>\n              <th scope=\"row\" className=\"px-3 py-2.5 text-start\">{msg(\"run-ledger.total\", \"Total\")}</th>\n              <td className=\"px-3 py-2.5 text-end tabular-nums\">{formatDuration(total.ms)}</td>\n              <td className=\"px-3 py-2.5 text-end tabular-nums\">{formatTokens(total.tokens)}</td>\n              <td className=\"px-3 py-2.5 text-end tabular-nums\">{money(total.cost, currency, locale)}</td>\n            </tr>\n          </tfoot>\n        </table>\n      </div>\n    </div>\n  )\n}\n\nexport { RunLedger, formatDuration, type LedgerRow, type RunLedgerProps, type SortKey }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/run-ledger.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "cost",
      "usage",
      "ledger",
      "budget",
      "tokens",
      "table"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "rows is [{ id, label, kind?, tokensIn?, tokensOut?, ms, cost }] with cost as a number in currency (default USD). Optional budget (same currency), warnAt (80), locale (en-US), defaultSort. Columns sort by label, time, tokens or cost; totals are computed.",
      "whenToUse": [
        "Showing where an agent run's time and money went",
        "A cost breakdown people check before approving a repeat run"
      ],
      "whenNotToUse": [
        "Plan quotas and billing limits; use usage-meter",
        "The context window; use token-meter",
        "A general data table; use data-table"
      ],
      "composesWith": [
        "token-meter",
        "agent-lanes",
        "parallel-attempts",
        "trace"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Reaches each column's sort button"
        },
        {
          "keys": "Enter / Space",
          "action": "Sorts by the column, then reverses; aria-sort says which way"
        },
        {
          "keys": "Screen readers",
          "action": "A real table with a caption, row headers and a total row; the budget is a meter with a value such as '$4.20 of $5.00. 80 cents left in the budget.'"
        },
        {
          "keys": "Colour",
          "action": "The warning and over-budget states add an icon and words; the top cost row says 'Most expensive'"
        }
      ],
      "customization": [
        "currency and locale",
        "budget and warnAt",
        "defaultSort",
        "kind text on each row"
      ]
    },
    "statesNone": "A presentational breakdown: the rows are passed in, so it has nothing of its own to load or submit.",
    "examples": [
      "run-ledger-demo",
      "run-ledger-over"
    ],
    "url": "https://ui.ballmac.com/components/run-ledger"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}