SLOPSHOPPER

vitals

A light, framed live dashboard above the prompt, in the terminal and in the desktop app: model and effort, context and account-wide plan-limit meters (5-hour…

newpanebandguardcommandtoast
★ 4v0.9.0MITupdated 2026-10-09naicud/cc-vitals/plugins/vitals
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vitals
│ ┃ vitals ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ VITALS 🧠 Opus 5.5 ⚡ — ⏺ Read(src/auth.ts) │ ┃ │ 📁 app ●2 ⏳ 30m · 1 prompt 💸 $0.42 ⎿ Read 6 lines │ ┃ │ ⛽ CTX ██░░ 49% 97k/200k │ 🗜 COMPACT ──── ⏺ Update(src/auth.ts) │ ┃ │ ⏳ 5H LIMIT █▎░░ 31% ↻ NaNd NaNh ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ 🔥 TOKENS 🧊 cache warm · expi │ ┃ │ │ IN │ OUT │ CACHE R │ CACH ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ────────┼────────┼────────┼─────────┼───── │ ┃ │ turn │ 2.1k │ 1.5k │ 91k │ 4 ✻ Worked for 42s · done 4:20 PM │ ┃ │ session │ 2.1k │ 1.5k │ 91k │ 4 │ ┃ ╰─────────────────────────────────────────── › /vitals │ ┃ ╭─────────────────────────────────────────── ⎿ vitals: Vitals: medium · vitals, context, agents. │ ┃ │ 🤖 AGENTS no sub │ ┃ │ │ AGENT │ MODEL │ TOKEN │ ┃ │ ──┼──────────────────┼─────────────┼────── │ ┃ │ ◆ │ main │ Opus 5.5 │ 99 │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 🔧 TOOLS 9 calls │ ┃ │ │ TOOL │ CALLS │ ERR │ NOW │ ┃ │ ──┼─────────────────┼───────┼─────┼─────── │ ┃ │ · │ Bash │ 4 │ 1 │ │ ┃ │ · │ Write │ 2 │ 0 │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ VITALS 🧠 Opus 5.5 ⚡ — 📁 app ●2 ⏳ 30m · 1 prompt 💸 $0.42 🔥 $0.84/h │ │ ⛽ CTX ███████████▎░░░░░░░░░░░ 49% 97k/200k │ 🗜 COMPACT ─────────────────────── — auto off │ │ ⏳ 5H LIMIT ███████▏░░░░░░░░░░░░░░░ 31% ↻ NaNd NaNh │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🔥 TOKENS turn · in 2.1k out 1.5k · R 91k W 4.3k · hit 93% · session · in 2.1k out 1.5k · R 91k W 4.3k · hit 93% · 🤖 AGENTS no subagents yet 🔧 TOOLS Bash 4 · Write 2 · Read 1 · Grep 1 · Edit 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ VITALS 🧠 Opus 5.5 ⚡ — 📁 app ●2 ⏳ 30m · 1 prompt 💸 $0.42 🔥 $0.84/h │ │ ⛽ CTX ███████████▎░░░░░░░░░░░ 49% 97k/200k │ 🗜 COMPACT ─────────────────────── — auto off │ │ ⏳ 5H LIMIT ███████▏░░░░░░░░░░░░░░░ 31% ↻ NaNd NaNh │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ 🔥 TOKENS turn · in 2.1k out 1.5k · R 91k W 4.3k · hit 93% · session · in 2.1k out 1.5k · R 91k W 4 🤖 AGENTS no subagents yet 🔧 TOOLS Bash 4 · Write 2 · Read 1 · Grep 1 · Edit 1
Pane · vitals
╭──────────────────────────────────────────────────────────╮ │ ◆ VITALS 🧠 Opus 5.5 ⚡ — │ │ 📁 app ●2 ⏳ 30m · 1 prompt 💸 $0.42 🔥 $0.84/h │ │ ⛽ CTX ██░░ 49% 97k/200k │ 🗜 COMPACT ──── — auto off │ │ ⏳ 5H LIMIT █▎░░ 31% ↻ NaNd NaNh │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 🔥 TOKENS 🧊 cache warm · expires in 59m │ │ │ IN │ OUT │ CACHE R │ CACHE W │ HIT │ │ ────────┼────────┼────────┼─────────┼─────────┼───── │ │ turn │ 2.1k │ 1.5k │ 91k │ 4.3k │ 93% │ │ session │ 2.1k │ 1.5k │ 91k │ 4.3k │ 93% │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 🤖 AGENTS no subagents yet │ │ │ AGENT │ MODEL │ TOKENS │ TIME │ │ ──┼──────────────────┼─────────────┼────────┼─────── │ │ ◆ │ main │ Opus 5.5 │ 99k │ 30m │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 🔧 TOOLS 9 calls · 1 error │ │ │ TOOL │ CALLS │ ERR │ NOW │ │ ──┼─────────────────┼───────┼─────┼───────────────── │ │ · │ Bash │ 4 │ 1 │ │ │ · │ Write │ 2 │ 0 │ │ │ · │ Read │ 1 │ 0 │ │ │ · │ Grep │ 1 │ 0 │ │ │ · │ Edit │ 1 │ 0 │ │ ╰──────────────────────────────────────────────────────╯ 📊 USAGE no history: unreadable ccusage output
Pane · vitals-report
No usage history: unreadable ccusage output
README

cc-vitals

A Claude Code mod that puts your session's vitals in a framed live dashboard above the prompt — in the terminal and in the desktop app's Code tab. Light by design: it draws what Claude Code already hands it and runs anything slow rarely, in the background.

Model and reasoning effort, context and plan limits (account-wide, as claude.ai counts them), cost and burn rate, tokens and cache per turn and session, auto-compaction, every subagent with its own model and effort, background shells, the tool running right now, and your weekly and monthly spend.

vitals-high: the vitals box on top; tokens, context and usage on the left; agents and tools on the right

Every part has its own box. ◆ VITALS sits on top across the whole width: the header, four bars, ⛽ CTX (context window), 🗜 COMPACT (how far the context is on its way to auto-compaction), ⏳ 5H LIMIT and 📅 WEEKLY (plan limits; a model's own weekly limit and 💳 CREDITS join them when they count), and the 🔮 FORECAST of both limits at your current pace. Under it, on a terminal 150 columns wide or more, two columns: 🔥 TOKENS, 🧩 CONTEXT and 📊 USAGE on the left, 🤖 AGENTS, 🔧 TOOLS and 🐚 SHELLS on the right, agents on top. Narrower, one column: tokens, agents, tools, shells, context, usage.

The band never scrolls (at most 40 rows). Every section gets one line first, then the most important ones grow to their full box while they fit: tokens, agents, tools, context, usage, shells. A short terminal, or one where Claude's progress takes the space, gets one line each instead of losing sections off the bottom.

Commands:

CommandShows
/vitals-highEverything: the vitals box, tokens, context, usage, agents, tools, shells. The default
/vitals-mediumThe vitals box, 🧩 context and 🤖 agents
/vitals-lowThe vitals box alone: model, effort, session, cost, the bars and the forecast
/vitalsThe next level down: high → medium → low → high
/vitals paneA pane with every section in full: every agent, every shell, every tool
/vitals reportThe usage report: 🎯 your account's plan limits (each model's own weekly limit, usage credits, and this week's limit by product: Claude Code, chats, Cowork), then the last 14 days, the last 6 weeks, this month and the last, and this month's models, each with bars

The level you pick stays for the sessions that follow.

Screenshots

/vitals-low: the vitals box alone, in a terminal 200 columns wide.

vitals-low: model, effort, folder and branch, session time and cost, burn rate; bars for context, compaction, 5-hour and weekly limits; the forecast of both limits

/vitals-medium: the vitals box, what fills the context, and the agents side by side.

vitals-medium: the vitals box; the context bar in /context's colours with its legend (system prompt, tools, MCP, memory, skills, messages, autocompact buffer, free space); the agents table with the main loop and a Haiku subagent, their model, effort, tokens, hit rate and time

/vitals-high: everything. With the rows this terminal had, 📊 usage got one line; a taller one gets its full table.

vitals-high: the vitals box; tokens per turn and session with cache read and write and hit rate; the context bar; usage today, this week and this month with a sparkline; the agents table; the tools table with calls, errors and a usage bar

/vitals report: the usage report in a pane. Since 0.9 it opens with 🎯 your account's plan limits, read from claude.ai; below them, from ccusage: the last 14 days, the last 6 weeks, this month and the last, and this month's models.

<img src="docs/screenshots/vitals-report.png" alt="vitals report: cost, tokens and top model per day for 14 days with bars; cost per week for 6 weeks with the change against the week before; this month and last; this month's models with their share" width="600">

/vitals pane: every section in full, in a pane beside the conversation: every agent, every shell, every tool.

<img src="docs/screenshots/vitals-pane.png" alt="vitals pane: the vitals box, tokens, context, agents, tools and usage stacked in full, in a narrow side pane" width="600">

What it shows

SectionContents
Header🧠 model, ⚡ the reasoning effort the last request used (pips out of five), 📁 folder, 🌿 branch (🌳 in a worktree), ahead/behind, changed files, ⏳ session age, prompts, 💸 cost, 🔥 burn rate per hour
Meters⛽ CTX, the context window (against the auto-compact window when one is set, as /context does); 🗜 COMPACT, the context against the auto-compact threshold, tokens left and compactions so far; ⏳ 5H LIMIT and 📅 WEEKLY plan limits with reset countdowns, as claude.ai's usage page counts them: every session, machine and claude.ai chat on the account, not only this session; ▸ marks the one the server names as the limit that counts now. A model's own weekly limit (📅 FABLE) once it is used, graded above normal or the one that counts now, and 💳 CREDITS, spent against the month's limit, while usage credits are on or spent; /vitals pane shows them always. Bars at an eighth of a cell, two to a row when the terminal is narrow
🔮 ForecastEach plan limit at the pace you spend it: the last hour's pace once there are ten minutes of it, else the window's average. Either when it runs out before its reset (⚠), or where it will stand at the reset (✓)
🧩 ContextWhat fills the context, as /context breaks it down: one bar in its colours (system prompt, tools, memory, skills, messages, ░ free, ▒ autocompact buffer) and a legend with tokens and shares. An estimate, read every 5 minutes
🔥 TokensLast main turn and whole session (subagents included): in, out, cache read, cache write, hit rate, total; idle time, 🧊 cache warm with the time until it expires, or 🥶 cold past the prompt-cache TTL. An interrupted turn keeps the last counted one on show
🤖 AgentsThe main loop and every subagent: status (spinner while it runs, ✓ ✗ ■ when it ended), task, type, the model and effort its requests actually used, tokens, hit rate, tool calls, time. The band shows the ones running; one that ends keeps its ✓ or ✗ three seconds, then leaves. /vitals pane keeps every one of the session
🔧 ToolsOne row per tool the session called: a spinner while one runs, calls, errors, a bar of its share of the calls, and what runs now (elapsed, how many at once, which agent). MCP tools by their short name
📊 UsageToday, this week (Monday first) and this month across every Claude Code session on the machine, each against the same days of the period before, the top models, a 14-day sparkline
🎯 Plan limits (report)Your account as claude.ai reports it: the 5-hour and weekly windows, each model's own weekly limit with the share of the weekly it may use, usage credits, and 🧭 who spent this week's limit by product (Claude Code, chats, Cowork, other). Read with your Claude login; absent with an API key or a cloud provider
🐚 ShellsBackground shells: id, command, which agent started it, status, time. Ended by the task notification or a TaskStop; the band shows the running ones and drops an ended one after three seconds, /vitals pane keeps them all

Meters turn amber at 80% and red at 95%, or sooner when the server grades a plan limit warning or critical; a cache hit rate under 50% and a cold cache are flagged. A toast pops up when a plan limit crosses 80% and again at 95%, once per window. Narrow windows drop the least useful table columns first.

Hit rate is cache read over everything the request sent: read / (in + read + write).

Light by design

WorkHow often
Context, plan limits, costAs Claude Code measures them: the figures come with the event, no call is made
Plan limits, usage credits and the weekly limit by product, across the account (/api/oauth/usage, the endpoint /usage reads)Every 5 minutes and after a turn, at most once a minute; held off as long as the endpoint asks after a refusal. A read of usage, not a model request: it spends no tokens and no plan usage
Model, effort, tokens, tools, agents, shellsFrom the events that already happen (each request, turn, tool call, notification)
git status and rev-parseAt most every 20 seconds
The /context estimate (auto-compact threshold, context breakdown)Every 5 minutes and after a compaction
ccusage claude dailyIn the background, at most every 15 minutes, kept across sessions so a new one draws it at once
RedrawOnce a second only while something runs (spinners, elapsed times); idle, only when a value changes

Install

Requirements:

WhatWhyInstall
Claude Code v2.1.287 or laterMods (function-hook plugins)claude update
ccusage on the PATHThe 📊 usage section and /vitals report (today, week, month, models)npm i -g ccusage
gitThe folder and branch in the headeralready there on most machines

Without ccusage the band still works and the usage section says ccusage not found.

  1. Install ccusage and check it answers:
   npm i -g ccusage
   ccusage claude daily --since $(date +%Y%m01)
  1. Inside a Claude Code terminal session, add the marketplace and install the mod:
   /plugin marketplace add naicud/cc-vitals
   /plugin install vitals@naicud
   /reload-plugins

Or from a shell:

   claude plugin marketplace add naicud/cc-vitals
   claude plugin install vitals@naicud
  1. Turn on auto-update, so every new release reaches all your sessions: run /plugin, open Marketplaces, pick naicud, choose Enable auto-update. Or set it in ~/.claude/settings.json:
   {
     "extraKnownMarketplaces": {
       "naicud": { "source": { "source": "github", "repo": "naicud/cc-vitals" }, "autoUpdate": true }
     }
   }

Claude Code then checks the marketplace a few minutes into each interactive session, updates the plugin on disk and says Plugin updated: vitals · Run /reload-plugins to apply; the next session starts on the new version.

  1. The band appears above the prompt once the session has its first measurement. The desktop app reads the same ~/.claude plugins, so it shows up in its Code tab too (start a new session there).

The band replaces most of what a statusLine script shows, so you can drop yours (statusLine in ~/.claude/settings.json) or keep it for other things.

To update by hand (without auto-update): claude plugin marketplace update naicud && claude plugin update vitals@naicud, then restart or /reload-plugins.

Troubleshooting

SymptomCause and fix
📊 USAGE no history: ccusage not foundccusage is not on the PATH Claude Code was started with: install it, restart Claude Code
Usage report shows no data for older daysClaude Code deletes transcripts after cleanupPeriodDays (30 by default), so ccusage, and the report, only reach that far back. Raise it in ~/.claude/settings.json ("cleanupPeriodDays": 365) to keep a longer history, at the cost of disk space. Periods that start before the first day on record are not compared against
Usage costs look too lowccusage could not reach its price list and priced new models at zero: run ccusage claude daily once online
One line per section instead of tablesThe band has few rows (a short terminal, or Claude's progress is taking them): make the terminal taller, or /vitals pane for everything in full
No band at allClaude Code older than v2.1.287, or the plugin disabled (claude plugin list, then claude plugin enable vitals@naicud)
⏳ 5H LIMIT or 📅 WEEKLY a point behind claude.aiThe account's usage is read every 5 minutes and after each turn: usage elsewhere shows within 5 minutes. Signed in with an API key, Bedrock or Vertex there is no account usage to read, and the limits are the ones this session's last response reported
No 📅 model limit or 💳 CREDITS barThe band shows a model's own weekly limit only once it counts (used, graded above normal, or the active one) and credits only while they are on or spent: /vitals pane and /vitals report show them always. With an API key or a cloud provider there are none to read
Less than you expect/vitals-low or /vitals-medium is on, and the level stays across sessions: /vitals-high brings everything back

Settings

One option, cache_ttl: how long the main conversation's prompt cache lives, 1h (default, Claude subscription within plan usage) or 5m (API billing, cloud providers, usage credits). It only drives the (cache cold) warning. Change it in /plugin or /config.

What it runs, reads and sends

  • Runs git, two fixed read-only commands in the session's folder with a 5-second timeout: git status --porcelain=v2 --branch and git rev-parse --git-dir --git-common-dir; neither contacts a remote. And ccusage claude daily --json --since <first of last month> for the usage section, when ccusage is installed (npm i -g ccusage): it reads Claude Code's local transcripts and may fetch model prices; without it the usage section says so and everything else works.
  • Reads through the mods API: session usage (context, cost, plan limits), folder, model, prompt count, the agent roster, each model request's model and effort, each finished turn's duration and token counts, each tool call's name (and, for Bash, the command and its description; for Agent, the agent id), the ids and statuses in task notifications, and the effort row of /config until the first request reports one. It never reads response text, files, environment variables or credentials.
  • Sends one request of its own, a GET https://api.anthropic.com/api/oauth/usage for the plan limits, usage credits and the weekly limit by product, through Claude Code with the session's own Claude login: the plugin holds an opaque handle, never the token. Not sent with an API key or a cloud provider, nor while CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set. It writes no files. Session state lives in $.state; the last ccusage report is kept in the plugin's $.store.
  • Changes nothing: every hook passes the event through unchanged.

Develop

git clone https://github.com/naicud/cc-vitals
claude --plugin-dir cc-vitals/plugins/vitals     # try it for one session
claude plugin validate cc-vitals/plugins/vitals
claude plugin test cc-vitals/plugins/vitals

Layout:

.claude-plugin/marketplace.json   the "naicud" marketplace
plugins/vitals/
  .claude-plugin/plugin.json      manifest and the cache_ttl option
  hooks/register.tsx              hooks, state atoms, refresh cadence, /vitals, render entries
  hooks/collect.ts                pure folds: place, meters, agents, shells, tool counts
  hooks/band.tsx                  the vitals box, the meters and the row-budget layout
  hooks/sections.tsx              the section boxes: tokens, agents, tools, shells, usage
  hooks/report.ts                 ccusage parsing and day/week/month folds
  hooks/forecast.ts               plan-limit pace and forecast
  hooks/report-view.tsx           the /vitals report pane
  hooks/ui.tsx                    boxes, rules, ruled tables, meters, bars, sparklines
  hooks/format.ts                 number, model, status and token formatting
  hooks/vitals.test.tsx           tests against the engine's test kit
  types/index.d.ts                $.state contract

Credits

Built on desktop-statusline by George Liu (MIT): the desktop band, limit meters and git row come from there. cc-vitals adds the terminal surface, the framed dashboard, reasoning effort, the token and cache tables, subagent and shell tracking, live tools, context composition, limit forecasts, account-wide limits per model and product with usage credits, compaction, usage reports, the three detail levels and the pane.

Licence

MIT. See LICENSE.

Source 10 files
hooks/register.tsx 552 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, SessionUsage } from 'claude-code'
3
4import type { AgentStat, ContextPart, Limit, LiveTool, RunStatus, ShellStat, Snapshot, Tokens, View } from '../types'
5import { drawAll, drawLevel } from './band'
6import type { BandInput } from './band'
7import {
8  addShell,
9  agentStep,
10  agentTokens,
11  agentTool,
12  compactionOf,
13  contextPartsOf,
14  configuredEffort,
15  countError,
16  countTool,
17  endRun,
18  isBusy,
19  limitWarnings,
20  mergeLimits,
21  mergeRoster,
22  metersOf,
23  parseNotification,
24  parsePlanUsage,
25  placeOf,
26  startLive,
27} from './collect'
28import { addSample, forecast } from './forecast'
29import type { Sample } from './forecast'
30import { NO_TOKENS, addTokens, toTokens } from './format'
31import { historySince, localDate, parseDaily } from './report'
32import { drawReport } from './report-view'
33import type { Canvas } from './ui'
34
35// The session's values, declared in ../types: kept by the host across reloads, gone with the session.
36const snapshot = atom({ plugin: 'vitals', key: 'snap' } as const, null)
37const warned = atom({ plugin: 'vitals', key: 'warned' } as const, [])
38const lastTurn = atom({ plugin: 'vitals', key: 'lastTurn' } as const, null)
39const totals = atom({ plugin: 'vitals', key: 'totals' } as const, null)
40const compactions = atom({ plugin: 'vitals', key: 'compactions' } as const, null)
41const effort = atom({ plugin: 'vitals', key: 'effort' } as const, null)
42const view = atom({ plugin: 'vitals', key: 'view' } as const, 'high')
43const agents = atom({ plugin: 'vitals', key: 'agents' } as const, [])
44const shells = atom({ plugin: 'vitals', key: 'shells' } as const, [])
45const live = atom({ plugin: 'vitals', key: 'live' } as const, [])
46const tools = atom({ plugin: 'vitals', key: 'tools' } as const, null)
47const history = atom({ plugin: 'vitals', key: 'history' } as const, null)
48const historyProblem = atom({ plugin: 'vitals', key: 'historyProblem' } as const, null)
49const plan = atom({ plugin: 'vitals', key: 'plan' } as const, null)
50
51// What each kind of work costs decides how often it runs: a measurement is free and drawn at
52// once; git is two processes; the /context estimate walks the context; ccusage reads every
53// transcript on the machine; the account's usage endpoint is one request, read sparingly.
54const REFRESH_MS = 60_000
55const GIT_EVERY_MS = 20_000
56const BREAKDOWN_EVERY_MS = 5 * 60_000
57const HISTORY_EVERY_MS = 15 * 60_000
58const PLAN_EVERY_MS = 5 * 60_000
59const PLAN_GAP_MS = 60_000
60const PLAN_TIMEOUT_MS = 30_000
61const PLAN_PAUSE_MS = 5 * 60_000
62const PLAN_PAUSE_MAX_MS = 60 * 60_000
63// The plan limits as claude.ai's usage page reads them, every session and surface of the account
64// counted: the session's own Claude login, through the host. It reads usage, it spends none.
65const PLAN_URL = 'https://api.anthropic.com/api/oauth/usage'
66const OAUTH_BETA = 'oauth-2025-04-20'
67const TICK_MS = 1000
68const MAX_BAND_ROWS = 40
69const PANE_ID = 'vitals'
70const REPORT_ID = 'vitals-report'
71const HISTORY_KEY = 'history'
72const LEVEL_KEY = 'level'
73const LEVEL_NAMES: Record<View, string> = { low: 'low · the vitals box', medium: 'medium · vitals, context, agents', high: 'high · everything' }
74const NEXT_LEVEL: Record<View, View> = { high: 'medium', medium: 'low', low: 'high' }
75
76/** A level read back from state or the store: anything else, an older value included, is high. */
77const asLevel = (value: unknown): View => (value === 'low' || value === 'medium' ? value : 'high')
78
79// Module memory: what the slow reads returned last, and when. A reload starts it over.
80let gitAt = 0
81let gitPlace: ReturnType<typeof placeOf> | null = null
82let breakdownAt = 0
83let compaction: ReturnType<typeof compactionOf> = { compactWindow: null, autoCompactAt: null }
84let contextParts: ContextPart[] | null = null
85// The plan limits' recent readings, for the pace of the forecast.
86const limitSamples = new Map<string, Sample[]>()
87let isReadingHistory = false
88let planAt = 0
89let planPausedUntil = 0
90let planReadingSince: number | null = null
91
92// Each command is written out in full at its call; this only reads the result.
93async function output(run: Promise<ProcessRunResult>) {
94  try {
95    const ran = await run
96    return ran.exitCode === 0 ? ran.stdout.trim() : null
97  } catch {
98    return null
99  }
100}
101
102async function setAgents($: EngineInterface, fn: (list: AgentStat[]) => AgentStat[]) {
103  const was = await read($, agents)
104  const next = fn(was)
105  if (JSON.stringify(next) !== JSON.stringify(was)) await update($, agents, () => next)
106}
107
108async function setShells($: EngineInterface, fn: (list: ShellStat[]) => ShellStat[]) {
109  const was = await read($, shells)
110  const next = fn(was)
111  if (JSON.stringify(next) !== JSON.stringify(was)) await update($, shells, () => next)
112}
113
114/** A measurement's meters, its plan limits joined with the usage endpoint's last reading. */
115async function metersNow($: EngineInterface, measured: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, now: number) {
116  const meters = metersOf(measured, compaction.compactWindow)
117  return { ...meters, limits: mergeLimits(meters.limits, (await read($, plan))?.limits ?? [], now) }
118}
119
120/** The plan limits on show: a sample for each forecast, a toast for each threshold crossed. */
121async function noteLimits($: EngineInterface, limits: Limit[], now: number) {
122  for (const l of limits) limitSamples.set(l.kind, addSample(limitSamples.get(l.kind) ?? [], { at: now, percent: l.percent }))
123  const warnings = limitWarnings(limits, await read($, warned), now)
124  for (const w of warnings) $.ui.toast(w.text, { timeoutMs: 8000 })
125  if (warnings.length > 0) await update($, warned, s => [...s, ...warnings.map(w => w.key)].slice(-50))
126}
127
128/** Draws a measurement into the meters at once: no call, the figures came with the event. */
129async function applyMeasure($: EngineInterface, measured: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, now: number) {
130  const meters = await metersNow($, measured, now)
131  await update($, snapshot, s => (s === null ? s : { ...s, ...meters, autoCompactAt: compaction.autoCompactAt, contextParts, at: now }))
132  await noteLimits($, meters.limits, now)
133}
134
135/** How long a refusal (401, 403, 429) holds the usage endpoint off: its Retry-After, else 5 minutes. */
136const pauseOf = (retryAfter: string | undefined, now: number) => {
137  const seconds = Number(retryAfter)
138  const until = retryAfter === undefined || retryAfter.trim() === '' ? NaN : Number.isFinite(seconds) ? now + seconds * 1000 : Date.parse(retryAfter)
139  return Math.min(Number.isFinite(until) && until > now ? until - now : PLAN_PAUSE_MS, PLAN_PAUSE_MAX_MS)
140}
141
142/**
143 * The plan limits from the account's usage endpoint, at most once a minute and never while it
144 * holds Vitals off. With no Claude login (an API key, a cloud provider) nothing is asked, and a
145 * failed read changes nothing: the last API response's figures stay.
146 */
147async function refreshPlan($: EngineInterface) {
148  const now = await $.clock.now()
149  const isReading = planReadingSince !== null && now - planReadingSince < PLAN_TIMEOUT_MS
150  if (isReading || now < planPausedUntil || now - planAt < PLAN_GAP_MS) return
151  planAt = now
152  planReadingSince = now
153  try {
154    const auth = await $.session.authorize()
155    if (auth === null || auth.kind !== 'bearer') return
156    const answer = await $.http.fetch(PLAN_URL, { auth: auth.handle, headers: { 'anthropic-beta': OAUTH_BETA } })
157    if (answer.status === 401 || answer.status === 403 || answer.status === 429) {
158      planPausedUntil = now + pauseOf(answer.headers['retry-after'], now)
159      return
160    }
161    const usage = answer.ok ? parsePlanUsage(answer.text) : null
162    if (usage === null) return
163    // A read that hung past its timeout may land after a later one: the later stays.
164    const held = await read($, plan)
165    if (held !== null && held.at > now) return
166    await update($, plan, () => ({ at: now, ...usage }))
167    const snap = await update($, snapshot, s => (s === null ? s : { ...s, limits: mergeLimits(s.limits, usage.limits, now) }))
168    if (snap !== null) await noteLimits($, snap.limits, now)
169  } catch {
170    // Offline, nonessential traffic turned off, or a policy refused it: the figures on show stay.
171  } finally {
172    if (planReadingSince === now) planReadingSince = null
173  }
174}
175
176/** The whole snapshot: the cheap reads every time, git and the /context estimate when due. */
177async function refresh($: EngineInterface) {
178  const now = await $.clock.now()
179  const isBreakdownDue = now - breakdownAt >= BREAKDOWN_EVERY_MS
180  const [usage, cwd, prompts, model, roster] = await Promise.all([
181    isBreakdownDue ? $.session.usage({ breakdown: 'summary' }) : $.session.usage(),
182    $.session.cwd(),
183    $.session.turns(),
184    $.session.model(),
185    $.agent.list(),
186  ])
187  if (isBreakdownDue) {
188    breakdownAt = now
189    compaction = compactionOf(usage.context.breakdown)
190    contextParts = contextPartsOf(usage.context.breakdown) ?? contextParts
191  }
192  if (gitPlace === null || now - gitAt >= GIT_EVERY_MS) {
193    gitAt = now
194    const [gitStatus, gitDirs] = await Promise.all([
195      output($.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5000 })),
196      output($.process.run(['git', 'rev-parse', '--git-dir', '--git-common-dir'], { cwd, timeoutMs: 5000 })),
197    ])
198    gitPlace = placeOf(cwd, gitStatus, gitDirs)
199  }
200  if ((await read($, effort)) === null) {
201    const configured = await $.config.list().then(configuredEffort, () => null)
202    if (configured !== null) await update($, effort, () => configured)
203  }
204  const snap: Snapshot = {
205    at: now,
206    startedAt: usage.startedAt,
207    prompts,
208    model,
209    ...gitPlace,
210    ...(await metersNow($, usage, now)),
211    autoCompactAt: compaction.autoCompactAt,
212    contextParts,
213  }
214  await update($, snapshot, () => snap)
215  await setAgents($, list => mergeRoster(list, roster, now))
216  await applyMeasure($, usage, now)
217}
218
219/** Usage across every session from ccusage, kept in the store so a new session draws it at once. */
220async function refreshHistory($: EngineInterface, isForced: boolean) {
221  if (isReadingHistory) return
222  const now = await $.clock.now()
223  const held = await read($, history)
224  if (!isForced && held !== null && now - held.at < HISTORY_EVERY_MS) return
225
226  isReadingHistory = true
227  $.ui.invalidate('ui.render')
228  try {
229    const ran = await $.process.run(['ccusage', 'claude', 'daily', '--json', '--since', historySince(localDate(now))], { timeoutMs: 60_000 })
230    const days = ran.exitCode === 0 ? parseDaily(ran.stdout) : null
231    if (days === null) {
232      await update($, historyProblem, () => ({ at: now, reason: ran.exitCode === 0 ? 'unreadable ccusage output' : `ccusage exited ${ran.exitCode}` }))
233      return
234    }
235    await update($, history, () => ({ at: now, days }))
236    await $.store.set(HISTORY_KEY, { at: now, text: ran.stdout })
237  } catch {
238    await update($, historyProblem, () => ({ at: now, reason: 'ccusage not found (npm i -g ccusage)' }))
239  } finally {
240    isReadingHistory = false
241  }
242}
243
244/** The history the last session kept, if the store holds a readable one. */
245async function restoreHistory($: EngineInterface) {
246  if ((await read($, history)) !== null) return
247  const saved: unknown = await $.store.get(HISTORY_KEY)
248  if (typeof saved !== 'object' || saved === null) return
249  const at: unknown = Reflect.get(saved, 'at')
250  const text: unknown = Reflect.get(saved, 'text')
251  if (typeof at !== 'number' || typeof text !== 'string') return
252  const days = parseDaily(text)
253  if (days !== null) await update($, history, () => ({ at, days }))
254}
255
256/** Shows `level` now and in the sessions to come. */
257async function setLevel($: EngineInterface, level: View) {
258  await update($, view, () => level)
259  await $.store.set(LEVEL_KEY, level)
260  return { text: `Vitals: ${LEVEL_NAMES[level]}.` }
261}
262
263/** A model request: the main loop's sets the session's effort, a subagent's its own model and effort. */
264async function noteStep($: EngineInterface, agentId: string | undefined, model: string, level: string | null) {
265  if (agentId === undefined) {
266    if (level !== null && (await read($, effort)) !== level) await update($, effort, () => level)
267    return
268  }
269  const now = await $.clock.now()
270  await setAgents($, list => agentStep(list, agentId, model, level, now))
271}
272
273/** A finished turn: the main loop's is the last turn; every loop adds to the session's totals. */
274async function noteTurn($: EngineInterface, agentId: string | undefined, durationMs: number, model: string | null, used: Tokens | null) {
275  const [now, snap] = await Promise.all([$.clock.now(), read($, snapshot)])
276  const startedAt = snap?.startedAt ?? 0
277  if (agentId === undefined) {
278    // An interrupted turn reports no usage: the last counted one stays on show.
279    await update($, lastTurn, t => ({ at: now, durationMs, model, tokens: used ?? t?.tokens ?? null }))
280  } else if (used !== null) {
281    await setAgents($, list => agentTokens(list, agentId, used, now))
282  }
283  if (used === null) return
284  await update($, totals, t => {
285    const isSame = t?.since === startedAt
286    return {
287      since: startedAt,
288      turns: (isSame ? t.turns : 0) + (agentId === undefined ? 1 : 0),
289      tokens: addTokens(isSame ? t.tokens : NO_TOKENS, used),
290    }
291  })
292}
293
294/** A tool call starts: it shows live, counts for the session and for its agent. */
295async function noteToolStart($: EngineInterface, call: LiveTool) {
296  const snap = await read($, snapshot)
297  await update($, live, list => startLive(list, call))
298  await update($, tools, counts => countTool(counts, call.tool, snap?.startedAt ?? 0))
299  const agentId = call.agentId
300  if (agentId !== null) await setAgents($, list => agentTool(list, agentId, call.startedAt))
301}
302
303/** Ends a background shell or agent by its id, as a notification or a TaskStop reports it. */
304async function noteEnded($: EngineInterface, id: string, status: RunStatus) {
305  const now = await $.clock.now()
306  await setShells($, list => endRun(list, id, status, now))
307  await setAgents($, list => endRun(list, id, status, now))
308}
309
310/** Redraws once a second while something runs, so spinners and elapsed times move; idle, nothing. */
311async function tick($: EngineInterface) {
312  const [running, agentList, shellList, now] = await Promise.all([read($, live), read($, agents), read($, shells), $.clock.now()])
313  if (isBusy(running, agentList, shellList, now)) $.ui.invalidate('ui.render')
314}
315
316/** Everything a drawing reads, at one moment. */
317async function gather($: EngineInterface, canvas: Canvas, room: number, rows: number, isWorking: boolean, cacheTtlMs: number) {
318  const snap = await read($, snapshot)
319  if (snap === null) return null
320  const [now, turn, sessionTotals, compacted, level, agentList, shellList, running, toolCounts, past, problem, account] = await Promise.all([
321    $.clock.now(),
322    read($, lastTurn),
323    read($, totals),
324    read($, compactions),
325    read($, effort),
326    read($, agents),
327    read($, shells),
328    read($, live),
329    read($, tools),
330    read($, history),
331    read($, historyProblem),
332    read($, plan),
333  ])
334  const input: BandInput = {
335    canvas,
336    room,
337    rows,
338    isWorking,
339    now,
340    snap,
341    turn,
342    totals: sessionTotals,
343    compactions: compacted,
344    effort: level,
345    agents: agentList,
346    shells: shellList,
347    live: running,
348    tools: toolCounts,
349    history: past,
350    historyProblem: problem,
351    plan: account,
352    forecasts: snap.limits.flatMap(l => forecast(l, limitSamples.get(l.kind) ?? [], now) ?? []),
353    cacheTtlMs,
354  }
355  return input
356}
357
358/** A string field of a tool's result, which the hook sees untyped. */
359const field = (result: unknown, name: string) => {
360  if (typeof result !== 'object' || result === null || !(name in result)) return undefined
361  const value: unknown = Reflect.get(result, name)
362  return typeof value === 'string' ? value : undefined
363}
364
365export const register: Register = (on, options) => {
366  // The main conversation's prompt-cache TTL (the `cache_ttl` option): 1 hour on a Claude
367  // subscription within plan usage, 5 minutes with API billing, a cloud provider or usage credits.
368  const cacheTtlMs = options.cache_ttl === '5m' ? 5 * 60_000 : 60 * 60_000
369
370  on('session.start', async ($, e, next) => {
371    const started = await next(e)
372    await $.command.register({
373      name: 'vitals',
374      description: 'Vitals: next detail level (high → medium → low) · "/vitals pane" every agent and shell · "/vitals report" weekly and monthly usage',
375    })
376    await $.command.register({ name: 'vitals-low', description: 'Vitals: the vitals box alone (model, effort, cost, context, compaction, limits, forecast)' })
377    await $.command.register({ name: 'vitals-medium', description: 'Vitals: the vitals box, what fills the context, and the agents' })
378    await $.command.register({ name: 'vitals-high', description: 'Vitals: everything (tokens, context, usage, agents, tools, shells)' })
379    const saved: unknown = await $.store.get(LEVEL_KEY)
380    await update($, view, () => asLevel(saved))
381    await restoreHistory($)
382    await refresh($)
383    void refreshPlan($)
384    void refreshHistory($, false)
385    $.clock.every(REFRESH_MS, () => void refresh($))
386    $.clock.every(PLAN_EVERY_MS, () => void refreshPlan($))
387    $.clock.every(HISTORY_EVERY_MS, () => void refreshHistory($, false))
388    $.clock.every(TICK_MS, () => void tick($))
389
390    return started
391  })
392
393  on('command.run', { command: 'vitals' }, async ($, e) => {
394    const arg = e.args.trim()
395    if (arg === 'pane') {
396      await $.ui.open({ id: PANE_ID, title: 'Vitals · agents, shells, tokens' })
397      return { text: 'Vitals pane opened.' }
398    }
399    if (arg === 'report') {
400      void refreshHistory($, true)
401      void refreshPlan($)
402      await $.ui.open({ id: REPORT_ID, title: 'Vitals · usage report' })
403      return { text: 'Vitals usage report opened; refreshing from ccusage.' }
404    }
405    return setLevel($, NEXT_LEVEL[asLevel(await read($, view))])
406  })
407
408  on('command.run', { command: 'vitals-low' }, async $ => setLevel($, 'low'))
409  on('command.run', { command: 'vitals-medium' }, async $ => setLevel($, 'medium'))
410  on('command.run', { command: 'vitals-high' }, async $ => setLevel($, 'high'))
411
412  on('session.attach', async ($, e, next) => {
413    const attached = await next(e)
414    await refresh($)
415    void refreshPlan($)
416
417    return attached
418  })
419
420  // A measurement carries the context, the limits and the cost: drawn as they come, no call made.
421  on('session.measure', async ($, e, next) => {
422    const measured = await next(e)
423    await applyMeasure($, e, await $.clock.now())
424
425    return measured
426  })
427
428  // Each model request names its model and effort: the main loop's and every subagent's.
429  on('turn.step', async function* ($, e, next) {
430    await noteStep($, e.agentId, e.model, e.effort === undefined ? null : `${e.effort}`)
431
432    return yield* next(e)
433  })
434
435  on('turn.complete', async ($, e, next) => {
436    const completed = await next(e)
437    await noteTurn($, e.agentId, e.durationMs, e.usage?.model ?? null, e.usage ? toTokens(e.usage) : null)
438    if (!e.agentId) {
439      await refresh($)
440      void refreshPlan($)
441    }
442
443    return completed
444  })
445
446  // Every tool call shows live while it runs and counts for the session.
447  on('tool.call', async ($, e, next) => {
448    await noteToolStart($, { id: e.tool_use_id, tool: e.tool, agentId: e.agentId ?? null, startedAt: await $.clock.now() })
449    try {
450      const ran = await next(e)
451      if (ran.isError === true) {
452        const snap = await read($, snapshot)
453        await update($, tools, counts => countError(counts, e.tool, snap?.startedAt ?? 0))
454      }
455      return ran
456    } finally {
457      await update($, live, list => list.filter(t => t.id !== e.tool_use_id))
458    }
459  }).catch(($, e, next) => next(e))
460
461  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
462    const ran = await next(e)
463    const id = field(ran.result, 'backgroundTaskId')
464    if (id !== undefined) {
465      const shell: ShellStat = {
466        id,
467        command: e.command,
468        description: e.description ?? null,
469        agentId: e.agentId ?? null,
470        status: 'running',
471        startedAt: await $.clock.now(),
472        endedAt: null,
473      }
474      await update($, shells, list => addShell(list, shell))
475    }
476
477    return ran
478  }).catch(($, e, next) => next(e))
479
480  // A foreground agent ends with its call; a background one shows in the roster shortly after.
481  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
482    $.clock.after(2000, () => void refresh($))
483    const ran = await next(e)
484    const agentId = field(ran.result, 'agentId')
485    if (agentId !== undefined && field(ran.result, 'status') === 'completed') await noteEnded($, agentId, 'completed')
486
487    return ran
488  }).catch(($, e, next) => next(e))
489
490  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
491    const ran = await next(e)
492    const id = e.task_id ?? e.shell_id
493    if (id !== undefined && ran.isError !== true && ran.deny === undefined) await noteEnded($, id, 'killed')
494
495    return ran
496  }).catch(($, e, next) => next(e))
497
498  // A background shell or agent reports its end to the model as a task notification.
499  on('prompt.submit', async ($, e, next) => {
500    if (e.origin.kind === 'task-notification') {
501      for (const ended of parseNotification(e.text)) await noteEnded($, ended.id, ended.status)
502    }
503
504    return next(e)
505  }).catch(($, e, next) => next(e))
506
507  on('session.compact', async ($, e, next) => {
508    const result = await next(e)
509    if (e.trigger === 'precompute' || e.agentId || result.messages === undefined) return result
510
511    const [now, snap] = await Promise.all([$.clock.now(), read($, snapshot)])
512    const startedAt = snap?.startedAt ?? 0
513    await update($, compactions, c => ({
514      since: startedAt,
515      count: (c?.since === startedAt ? c.count : 0) + 1,
516      before: result.tokensBefore ?? null,
517      after: result.tokensAfter ?? null,
518      at: now,
519      trigger: e.trigger,
520    }))
521    // The window and the threshold may have moved: read them again on the next refresh.
522    breakdownAt = 0
523
524    return result
525  }).catch(($, e, next) => next(e))
526
527  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
528    if (e.props.hasSurvey) return next(e)
529
530    const canvas = { els: $.ui.resolve(e), surface: e.surface }
531    const input = await gather($, canvas, e.props.bodyColumns, e.props.maxRows, e.props.isWorking, cacheTtlMs)
532    if (input === null) return next(e)
533
534    return drawLevel(input, asLevel(await read($, view)), MAX_BAND_ROWS)
535  })
536
537  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
538    const canvas = { els: $.ui.resolve(e), surface: e.surface }
539    const input = await gather($, canvas, e.props.bodyColumns, 1000, false, cacheTtlMs)
540    if (input === null) return <canvas.els.Text dimColor>{'Waiting for the first measurement…'}</canvas.els.Text>
541
542    return drawAll({ ...input, isWorking: input.live.length > 0 })
543  })
544
545  on('ui.render', { component: 'Pane', requestId: REPORT_ID }, async ($, e) => {
546    const canvas = { els: $.ui.resolve(e), surface: e.surface }
547    const [now, past, problem, account] = await Promise.all([$.clock.now(), read($, history), read($, historyProblem), read($, plan)])
548
549    return drawReport(canvas, e.props.bodyColumns, now, past, problem, isReadingHistory, account)
550  })
551}
552
hooks/band.tsx 324 lines
1import type { RenderChildren } from 'claude-code'
2
3import type { View } from '../types'
4
5import { ago, cash, count, effortPips, limitShortLabel, money, prettyModel, until } from './format'
6import type { Part } from './format'
7import type { Forecast } from './forecast'
8import { agentsSection, contextSection, effortCell, shellsSection, tokensSection, toolsSection, usageSection } from './sections'
9import type { BandInput, Section } from './sections'
10import { ACCENT, CARD_CELLS, cellWidth, meter, meterChrome, parts } from './ui'
11
12export type { BandInput } from './sections'
13
14const LIMIT_ICONS: Record<string, string> = { five_hour: '⏳', seven_day: '📅' }
15const LIMIT_LABELS: Record<string, string> = { five_hour: '5H LIMIT', seven_day: 'WEEKLY' }
16
17/** A meter's bar never gets narrower than this; below it the meters go two to a row. */
18const MIN_BAR = 8
19const MAX_BAR = 24
20const METER_GAP = 3
21
22/** How soon a section gets rows when they are short: lower first. */
23const RANK = { tokens: 0, agents: 1, tools: 2, context: 3, usage: 4, shells: 5 }
24
25/** A terminal this wide splits the sections in two columns: numbers left, agents and work right. */
26const TWO_COLUMNS_FROM = 150
27const COLUMN_GAP = 2
28
29type Item = { label: string; percent: number | null; detail: string; severity?: string }
30
31/** The mark of the limit the server names as the one a single-value indicator shows. */
32const ACTIVE = '▸'
33
34/**
35 * The account's other usage meters: each of the server's other rows (a model's own weekly limit)
36 * and usage credits while they are on or spent. With `isEvery` false, the band's, a row shows only
37 * once it counts: used, graded above normal, or the active one.
38 */
39const accountItems = (input: BandInput, isEvery: boolean): Item[] => {
40  const plan = input.plan
41  if (plan === null) return []
42  const rows = plan.rows
43    .filter(r => isEvery || r.percent > 0 || r.isActive || r.severity !== 'normal')
44    .map(r => ({
45      label: `${r.isActive ? ACTIVE : ''}${r.kind.startsWith('weekly') ? '📅' : '⏳'} ${r.label.toUpperCase()}`,
46      percent: r.percent,
47      detail: r.resetsAt ? `↻ ${until(r.resetsAt, input.now)}` : '',
48      severity: r.severity,
49    }))
50  const c = plan.credits
51  if (c === null) return rows
52  const spent = c.limit === null ? `${cash(c.used, c.currency)} · no cap` : `${cash(c.used, c.currency)}/${cash(c.limit, c.currency)}`
53  const percent = c.limit === null || c.limit <= 0 ? null : Math.round((c.used / c.limit) * 100)
54  return [...rows, { label: '💳 CREDITS', percent, detail: `${spent}${c.isOn ? '' : ' · off'}` }]
55}
56
57/** Context, the road to auto-compaction, and the plan limits: every one a bar. */
58const meterItems = (input: BandInput, isEvery: boolean): Item[] => {
59  const { snap, compactions: c } = input
60  const done = c !== null && c.since === snap.startedAt ? c.count : 0
61  const compactAt = snap.autoCompactAt
62  const toCompact =
63    compactAt === null || snap.contextTokens === null
64      ? null
65      : { percent: Math.min(100, Math.round((snap.contextTokens / compactAt) * 100)), left: compactAt - snap.contextTokens }
66  const compactDetail =
67    compactAt === null ? 'auto off' : toCompact === null ? `at ${count(compactAt)}` : toCompact.left > 0 ? `${count(toCompact.left)} to go` : 'due'
68  return [
69    {
70      label: '⛽ CTX',
71      percent: snap.contextPercent,
72      detail: snap.contextTokens === null ? '' : `${count(snap.contextTokens)}/${count(snap.contextWindow)}`,
73    },
74    { label: '🗜 COMPACT', percent: toCompact?.percent ?? null, detail: `${compactDetail}${done > 0 ? ` · ×${done}` : ''}` },
75    ...snap.limits.map(l => ({
76      label: `${l.isActive === true ? ACTIVE : ''}${LIMIT_ICONS[l.kind] ?? '⏳'} ${LIMIT_LABELS[l.kind] ?? limitShortLabel(l.kind)}`,
77      percent: l.percent,
78      detail: l.resetsAt ? `↻ ${until(l.resetsAt, input.now)}` : '',
79      severity: l.severity,
80    })),
81    ...accountItems(input, isEvery),
82  ]
83}
84
85/** The meters in as few rows as keep every bar at least MIN_BAR wide: all in one, or two a row. */
86const meterRows = (items: Item[], room: number) => {
87  const fit = (perRow: number) => {
88    const rows = Array.from({ length: Math.ceil(items.length / perRow) }, (_, i) => items.slice(i * perRow, (i + 1) * perRow))
89    const widest = Math.max(...rows.map(r => r.reduce((s, m) => s + meterChrome(m.label, m.detail), 0) + METER_GAP * (r.length - 1)))
90    return { rows, bar: Math.max(4, Math.min(MAX_BAR, Math.floor((room - widest) / perRow))) }
91  }
92  const all = fit(items.length)
93  return all.bar >= MIN_BAR || items.length <= 2 ? all : fit(2)
94}
95
96const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
97
98/** A moment as a glance reads it: `14:05` today, `Thu 14:05` further off. */
99const clockTime = (ms: number, now: number) => {
100  const d = new Date(ms)
101  const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
102  return ms - now < 20 * 3_600_000 && d.getDate() === new Date(now).getDate() ? hhmm : `${WEEKDAYS[d.getDay()] ?? ''} ${hhmm}`
103}
104
105/** Each limit at this pace: when it runs out before its reset, or where it stands at the reset. */
106const forecastParts = (forecasts: Forecast[], now: number): Part[] =>
107  forecasts.map(f => {
108    const name = LIMIT_LABELS[f.kind] ?? limitShortLabel(f.kind)
109    const pace = f.pace === 'recent' ? 'last hour' : 'window avg'
110    return f.outAt === null
111      ? { text: `${name} ≈ ${f.atReset}% at reset ✓ (${pace})`, color: f.atReset >= 80 ? 'warning' : 'success' }
112      : { text: `${name} out at ${clockTime(f.outAt, now)}, reset ${clockTime(f.resetAt, now)} ⚠ (${pace})`, emphasis: 'warning' as const }
113  })
114
115/** The top box: model, effort, where, session, cost; then the meters, every one of them with `isEvery`. */
116const vitals = (input: BandInput, room: number, isEvery = false) => {
117  const { Box, Text } = input.canvas.els
118  const { snap } = input
119  const where =
120    `📁 ${snap.dir}` +
121    (snap.branch === null ? '' : `  🌿 ${snap.branch}${snap.isWorktree ? ' 🌳' : ''}`) +
122    `${snap.ahead ? ` ↑${snap.ahead}` : ''}${snap.behind ? ` ↓${snap.behind}` : ''}${snap.changed ? ` ●${snap.changed}` : ''}`
123  const ageMs = input.now - snap.startedAt
124  const burn = snap.costUsd !== null && ageMs >= 5 * 60_000 ? `  🔥 ${money(snap.costUsd / (ageMs / 3_600_000))}/h` : ''
125  const level = effortCell(input.effort)
126  const inner = room - CARD_CELLS
127  const { rows, bar } = meterRows(meterItems(input, isEvery), inner)
128  const ahead = forecastParts(input.forecasts, input.now)
129  const pips = input.effort === null ? '' : ` ${effortPips(input.effort)}`
130  const title = `◆ VITALS   🧠 ${prettyModel(snap.model)}   ⚡ ${level.text.toUpperCase()}${pips}`
131  const session = `${where}   ⏳ ${ago(ageMs)} · ${snap.prompts} prompt${snap.prompts === 1 ? '' : 's'}   `
132  const cost = snap.costUsd === null ? '' : `💸 ${money(snap.costUsd)}`
133  // Where the title and the session do not fit side by side, the session takes a row of its own.
134  const isStacked = cellWidth(title) + 2 + cellWidth(session + cost + burn) > inner
135  // The forecast wraps under its label; count the rows it takes.
136  const aheadWidth = ahead.reduce((sum, a, i) => sum + cellWidth(a.text) + (i > 0 ? 3 : 0), 0)
137  const aheadRows = ahead.length === 0 ? 0 : Math.max(1, Math.ceil(aheadWidth / Math.max(1, inner - 13)))
138  const sessionRow = (
139    <Box flexDirection="row" marginLeft={isStacked ? 0 : 2} flexShrink={1}>
140      <Box flexShrink={1}>
141        <Text dimColor wrap="truncate-end">{session}</Text>
142      </Box>
143      {cost !== '' && (
144        <Box flexShrink={0} marginLeft={1}>
145          <Text bold>{cost}</Text>
146        </Box>
147      )}
148      {burn !== '' && (
149        <Box flexShrink={0}>
150          <Text dimColor>{burn}</Text>
151        </Box>
152      )}
153    </Box>
154  )
155  return {
156    rows: 2 + (isStacked ? 2 : 1) + rows.length + aheadRows,
157    box: (
158      <Box flexDirection="column" width={room} borderStyle="round" borderColor={ACCENT} paddingX={1}>
159        <Box flexDirection={isStacked ? 'column' : 'row'} justifyContent="space-between">
160          <Box flexDirection="row" flexShrink={0}>
161            <Text bold color={ACCENT}>{'◆ VITALS'}</Text>
162            <Text>{'   🧠 '}</Text>
163            <Text bold>{prettyModel(snap.model)}</Text>
164            <Text>{'   ⚡ '}</Text>
165            <Text bold color={level.color} dimColor={level.dim}>{level.text.toUpperCase()}</Text>
166            {pips !== '' && <Text color={level.color}>{pips}</Text>}
167          </Box>
168          {sessionRow}
169        </Box>
170        {rows.map(row => (
171          <Box flexDirection="row">
172            {row.map((m, i) => (
173              <Box flexDirection="row">
174                {i > 0 && <Text dimColor>{' │ '}</Text>}
175                {meter(input.canvas, m.label, m.percent, m.detail, bar, m.severity)}
176              </Box>
177            ))}
178          </Box>
179        ))}
180        {ahead.length > 0 && (
181          <Box flexDirection="row">
182            <Box width={13} flexShrink={0}>
183              <Text bold color={ACCENT}>{'🔮 FORECAST'}</Text>
184            </Box>
185            {parts(input.canvas.els, ahead)}
186          </Box>
187        )}
188      </Box>
189    ),
190  }
191}
192
193type Placed = { section: Section | null; rank: number }
194
195/**
196 * Lays sections out in the rows there are, never scrolling. In rank order, every section gets
197 * one line first, then grows to its box while it fits; a section that does not get even its line
198 * is left out. They are drawn in the order given, whatever their rank.
199 */
200const layout = (input: BandInput, placed: Placed[], budget: number) => {
201  const { Box } = input.canvas.els
202  const present = placed.filter((p): p is { section: Section; rank: number } => p.section !== null)
203  const byRank = [...present].sort((a, b) => a.rank - b.rank)
204  const kept = new Set<Section>(byRank.slice(0, Math.max(0, budget)).map(p => p.section))
205  let left = budget - kept.size
206  const isFull = new Set<Section>()
207  for (const p of byRank) {
208    const extra = p.section.fullRows - 1
209    if (!kept.has(p.section) || extra > left) continue
210    isFull.add(p.section)
211    left -= extra
212  }
213  return (
214    <Box flexDirection="column">
215      {present.filter(p => kept.has(p.section)).map(p => (isFull.has(p.section) ? p.section.full() : p.section.mini()))}
216    </Box>
217  )
218}
219
220/** The numbers: tokens, what fills the context, and usage. */
221const numbers = (input: BandInput, room: number): Placed[] => [
222  { section: tokensSection(input, room), rank: RANK.tokens },
223  { section: contextSection(input, room), rank: RANK.context },
224  { section: usageSection(input, room), rank: RANK.usage },
225]
226
227/** The work: agents on top, then the tools and the shells; tables ask for what they could show. */
228const work = (input: BandInput, room: number, rows: number): Placed[] => [
229  { section: agentsSection(input, room, Math.max(1, rows - 12), 'running'), rank: RANK.agents },
230  { section: toolsSection(input, room, Math.max(1, Math.min(8, rows - 12))), rank: RANK.tools },
231  { section: shellsSection(input, room, Math.max(1, rows - 16), 'running'), rank: RANK.shells },
232]
233
234/** The dashboard: the vitals box on top, then the section boxes, sized to the rows the band has. */
235export const drawBand = (input: BandInput, maxBandRows: number) => {
236  const { Box } = input.canvas.els
237  const top = vitals(input, input.room)
238  const budget = Math.min(input.rows, maxBandRows) - top.rows
239  let body: RenderChildren
240  if (input.room < TWO_COLUMNS_FROM) {
241    const [tokens, context, usage] = numbers(input, input.room)
242    const none = { section: null, rank: 0 }
243    body = layout(input, [tokens ?? none, ...work(input, input.room, budget - 10), context ?? none, usage ?? none], budget)
244  } else {
245    const leftRoom = Math.floor((input.room - COLUMN_GAP) / 2)
246    const rightRoom = input.room - COLUMN_GAP - leftRoom
247    body = (
248      <Box flexDirection="row" columnGap={COLUMN_GAP}>
249        <Box width={leftRoom} flexShrink={0}>
250          {layout(input, numbers(input, leftRoom), budget)}
251        </Box>
252        <Box width={rightRoom} flexShrink={0}>
253          {layout(input, work(input, rightRoom, budget), budget)}
254        </Box>
255      </Box>
256    )
257  }
258  return (
259    <Box flexDirection="column">
260      {top.box}
261      {body}
262    </Box>
263  )
264}
265
266/** Every section in its box, for the pane, which scrolls. */
267export const drawAll = (input: BandInput) => {
268  const { Box } = input.canvas.els
269  const sections = [
270    tokensSection(input, input.room),
271    contextSection(input, input.room),
272    agentsSection(input, input.room, 40, 'all'),
273    toolsSection(input, input.room, 40),
274    shellsSection(input, input.room, 40, 'all'),
275    usageSection(input, input.room),
276  ]
277  return (
278    <Box flexDirection="column">
279      {vitals(input, input.room, true).box}
280      {sections.map(s => (s === null ? null : s.full()))}
281    </Box>
282  )
283}
284
285/** The medium dashboard: the vitals box, what fills the context, and the agents. */
286const drawMedium = (input: BandInput, maxBandRows: number) => {
287  const { Box } = input.canvas.els
288  const top = vitals(input, input.room)
289  const budget = Math.min(input.rows, maxBandRows) - top.rows
290  const placed = (contextRoom: number, agentsRoom: number) => ({
291    context: { section: contextSection(input, contextRoom), rank: 1 },
292    agents: { section: agentsSection(input, agentsRoom, Math.max(1, budget - 6), 'running'), rank: 0 },
293  })
294  let body: RenderChildren
295  if (input.room < TWO_COLUMNS_FROM) {
296    const { context, agents } = placed(input.room, input.room)
297    body = layout(input, [context, agents], budget)
298  } else {
299    const leftRoom = Math.floor((input.room - COLUMN_GAP) / 2)
300    const rightRoom = input.room - COLUMN_GAP - leftRoom
301    const { context, agents } = placed(leftRoom, rightRoom)
302    body = (
303      <Box flexDirection="row" columnGap={COLUMN_GAP}>
304        <Box width={leftRoom} flexShrink={0}>
305          {layout(input, [context], budget)}
306        </Box>
307        <Box width={rightRoom} flexShrink={0}>
308          {layout(input, [agents], budget)}
309        </Box>
310      </Box>
311    )
312  }
313  return (
314    <Box flexDirection="column">
315      {top.box}
316      {body}
317    </Box>
318  )
319}
320
321/** The band at its detail level: low the vitals box alone, medium with context and agents, high everything. */
322export const drawLevel = (input: BandInput, level: View, maxBandRows: number) =>
323  level === 'low' ? vitals(input, input.room).box : level === 'medium' ? drawMedium(input, maxBandRows) : drawBand(input, maxBandRows)
324
hooks/collect.ts 312 lines
1import type { AgentInfo, ConfigRow, SessionContextBreakdown, SessionUsage } from 'claude-code'
2
3import type { AgentStat, Breakdown, ContextPart, Credits, Limit, LiveTool, PlanRow, PlanUsage, RunStatus, ShellStat, ToolCounts, Tokens } from '../types'
4import { NO_TOKENS, addTokens, isActive, isShown, limitLabel, notifiedStatus, runStatus, until } from './format'
5
6// Pure folds over the session's values: register.tsx reads the engine and writes the results.
7
8const KEEP = 40
9const WARN_AT = [95, 80]
10
11/** Where the session works: folder and git state, from `git status --porcelain=v2 --branch` and `rev-parse`. */
12export const placeOf = (cwd: string, gitStatus: string | null, gitDirs: string | null) => {
13  const lines = gitStatus?.split('\n') ?? []
14  const head = lines.find(l => l.startsWith('# branch.head '))?.slice(14)
15  const ab = lines.find(l => l.startsWith('# branch.ab '))?.match(/\+(\d+) -(\d+)/)
16  const [gitDir, commonDir] = gitDirs?.split('\n') ?? []
17  return {
18    dir: cwd.split('/').pop() || cwd,
19    branch: head && head !== '(detached)' ? head : null,
20    isWorktree: gitDir !== commonDir,
21    ahead: Number(ab?.[1] ?? 0),
22    behind: Number(ab?.[2] ?? 0),
23    changed: lines.filter(l => l && !l.startsWith('#')).length,
24  }
25}
26
27/**
28 * The meters, from a measurement (`session.measure`'s input or `$.session.usage()`): context fill
29 * against the auto-compact window when one smaller than the model's is known, as /context does.
30 */
31export const metersOf = (
32  measured: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>,
33  compactWindow: number | null,
34) => {
35  const { tokens, window: modelWindow } = measured.context
36  const contextWindow = compactWindow === null ? modelWindow : Math.min(modelWindow, compactWindow)
37  return {
38    contextPercent: tokens === undefined ? null : Math.round((tokens / contextWindow) * 100),
39    contextTokens: tokens ?? null,
40    contextWindow,
41    costUsd: measured.cost?.usd ?? null,
42    limits: measured.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt ?? null })),
43  }
44}
45
46/** A field of an object in the endpoint's answer; undefined for anything else. */
47const field = (value: unknown, key: string): unknown => (typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined)
48
49const numberAt = (value: unknown, key: string) => {
50  const n = field(value, key)
51  return typeof n === 'number' && Number.isFinite(n) ? n : null
52}
53
54const textAt = (value: unknown, key: string) => {
55  const text = field(value, key)
56  return typeof text === 'string' ? text : null
57}
58
59/** A time the endpoint spells to the microsecond, as `Date` spells it; null when unreadable. */
60const isoOf = (text: string | null) => {
61  const at = text === null ? NaN : Date.parse(text)
62  return Number.isFinite(at) ? new Date(at).toISOString() : null
63}
64
65/** A percentage to one decimal, as the headers give it. */
66const tenths = (n: number) => Math.round(n * 10) / 10
67
68/** The windows the headers report too, each with the kind of the server's usage row that grades it. */
69const PLAN_WINDOWS = [
70  { kind: 'five_hour', row: 'session' },
71  { kind: 'seven_day', row: 'weekly_all' },
72]
73
74/**
75 * Usage credits from the answer's `extra_usage`, whose amounts are in the currency's minor units:
76 * null while they are off and nothing has been spent on them.
77 */
78const creditsOf = (extra: unknown): Credits | null => {
79  const used = numberAt(extra, 'used_credits') ?? 0
80  const isOn = field(extra, 'is_enabled') === true
81  if (!isOn && used <= 0) return null
82  const places = numberAt(extra, 'decimal_places')
83  const unit = 10 ** (places !== null && Number.isInteger(places) && places >= 0 && places <= 4 ? places : 2)
84  const limit = numberAt(extra, 'monthly_limit')
85  return { isOn, used: used / unit, limit: limit === null ? null : limit / unit, currency: (textAt(extra, 'currency') ?? 'USD').toUpperCase() }
86}
87
88/** The weekly limit's shares by product from the answer's `seven_day_breakdown`; null without rows. */
89const breakdownOf = (value: unknown): Breakdown | null => {
90  const rows = field(value, 'rows')
91  const shares = (Array.isArray(rows) ? rows : []).flatMap(r => {
92    const name = textAt(r, 'display_name') ?? textAt(r, 'key')
93    const percent = numberAt(r, 'percent')
94    return name === null || percent === null ? [] : [{ name, percent: tenths(percent) }]
95  })
96  return shares.length === 0 ? null : { asOf: isoOf(textAt(value, 'as_of')), rows: shares }
97}
98
99/**
100 * The account's usage in an answer of `/api/oauth/usage`, what claude.ai's usage page and `/usage`
101 * show: null for an answer that is not a JSON object. The 5-hour and weekly windows carry the grade
102 * of their usage row; the other rows (a model's own weekly limit) come as the server sends them,
103 * classified by kind, never by label. A window or a row the answer leaves out or nulls is left out.
104 */
105export const parsePlanUsage = (text: string): Omit<PlanUsage, 'at'> | null => {
106  let body: unknown
107  try {
108    body = JSON.parse(text)
109  } catch {
110    return null
111  }
112  if (typeof body !== 'object' || body === null || Array.isArray(body)) return null
113  const listed = field(body, 'limits')
114  const usageRows: unknown[] = Array.isArray(listed) ? listed : []
115  const shares = field(body, 'weekly_scoped_shares')
116  const ofWeekly = (index: number) =>
117    numberAt(Array.isArray(shares) ? shares.find(share => numberAt(share, 'limit_index') === index) : undefined, 'allowance_percent_of_weekly')
118  const limits = PLAN_WINDOWS.flatMap(({ kind, row }): Limit[] => {
119    const window = field(body, kind)
120    const percent = numberAt(window, 'utilization')
121    if (percent === null) return []
122    const graded = usageRows.find(r => textAt(r, 'kind') === row)
123    const severity = textAt(graded, 'severity')
124    const active = field(graded, 'is_active')
125    return [
126      {
127        kind,
128        percent: tenths(percent),
129        resetsAt: isoOf(textAt(window, 'resets_at')),
130        ...(severity === null ? {} : { severity }),
131        ...(typeof active === 'boolean' ? { isActive: active } : {}),
132      },
133    ]
134  })
135  const windowRows = new Set(PLAN_WINDOWS.map(w => w.row))
136  const rows = usageRows.flatMap((r, index): PlanRow[] => {
137    const kind = textAt(r, 'kind')
138    const percent = numberAt(r, 'percent')
139    if (kind === null || percent === null || windowRows.has(kind)) return []
140    const scope = field(r, 'scope')
141    return [
142      {
143        kind,
144        label: textAt(field(scope, 'model'), 'display_name') ?? textAt(field(scope, 'surface'), 'display_name') ?? kind,
145        percent: tenths(percent),
146        resetsAt: isoOf(textAt(r, 'resets_at')),
147        severity: textAt(r, 'severity') ?? 'normal',
148        isActive: field(r, 'is_active') === true,
149        ofWeekly: ofWeekly(index),
150      },
151    ]
152  })
153  return { limits, rows, credits: creditsOf(field(body, 'extra_usage')), breakdown: breakdownOf(field(body, 'seven_day_breakdown')) }
154}
155
156/** Two readings of one window end within this of each other; two windows of a kind end hours apart. */
157const SAME_WINDOW_MS = 60 * 60_000
158
159const endOf = (limit: Limit) => {
160  const end = limit.resetsAt === null ? NaN : Date.parse(limit.resetsAt)
161  return Number.isFinite(end) ? end : null
162}
163
164/** A limit's grade from the server, as fields to spread: none where it has not graded it. */
165const gradeOf = (limit: Limit) => ({
166  ...(limit.severity === undefined ? {} : { severity: limit.severity }),
167  ...(limit.isActive === undefined ? {} : { isActive: limit.isActive }),
168})
169
170/**
171 * One limit from two readings of it, `b` the usage endpoint's. Within a window usage only grows, so
172 * of two readings of the same window the higher is the truth, whichever source took it and when,
173 * graded as the endpoint graded that window; a window that has ended loses to one that has not, an
174 * earlier window to a later one, an unknown end to a known one.
175 */
176const pickLimit = (a: Limit, b: Limit, now: number): Limit => {
177  const [endA, endB] = [endOf(a), endOf(b)]
178  const [isOverA, isOverB] = [endA !== null && endA <= now, endB !== null && endB <= now]
179  if (isOverA !== isOverB) return isOverA ? b : a
180  if (endA === null && endB !== null) return b
181  if (endB === null && endA !== null) return a
182  if (endA !== null && endB !== null && Math.abs(endA - endB) >= SAME_WINDOW_MS) return endA > endB ? a : b
183  const higher = a.percent >= b.percent ? a : b
184  const graded = b.severity !== undefined || b.isActive !== undefined ? b : a
185  return { kind: higher.kind, percent: higher.percent, resetsAt: higher.resetsAt, ...gradeOf(graded) }
186}
187
188/**
189 * The plan limits from this session's last API response and the account's usage endpoint, one per
190 * window. Each end is set to the minute, so one window reads the same from either source (the
191 * headers spell it in whole seconds, the endpoint to the microsecond) and its toasts raise once.
192 */
193export const mergeLimits = (measured: Limit[], plan: Limit[], now: number): Limit[] =>
194  [...new Set([...measured, ...plan].map(l => l.kind))].flatMap(kind => {
195    const [a, b] = [measured.find(l => l.kind === kind), plan.find(l => l.kind === kind)]
196    const limit = a !== undefined && b !== undefined ? pickLimit(a, b, now) : (a ?? b)
197    if (limit === undefined) return []
198    const end = endOf(limit)
199    return [{ ...limit, resetsAt: end === null ? limit.resetsAt : new Date(Math.round(end / 60_000) * 60_000).toISOString() }]
200  })
201
202/** The rows of a `/context` breakdown that take room in the window: deferred tool schemas do not. */
203export const contextPartsOf = (breakdown: SessionContextBreakdown | undefined): ContextPart[] | null =>
204  breakdown === undefined
205    ? null
206    : breakdown.categories.flatMap(c =>
207        c.kind === 'deferred' || c.tokens <= 0 ? [] : [{ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }],
208      )
209
210/** The compaction window and threshold a `/context` breakdown reports, when auto-compaction is on. */
211export const compactionOf = (breakdown: SessionContextBreakdown | undefined) => ({
212  compactWindow: breakdown?.isAutoCompactEnabled ? breakdown.rawMaxTokens : null,
213  autoCompactAt: breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
214})
215
216/** The plan-limit toasts not raised yet in their window: a key to remember and the text to show. */
217export const limitWarnings = (limits: Limit[], seen: string[], now: number) =>
218  limits.flatMap(limit => {
219    const threshold = WARN_AT.find(t => limit.percent >= t)
220    const key = `${limit.kind}@${threshold}@${limit.resetsAt}`
221    if (threshold === undefined || seen.includes(key)) return []
222    const resets = limit.resetsAt ? ` · resets in ${until(limit.resetsAt, now)}` : ''
223    return [{ key, text: `${limitLabel(limit.kind)} usage limit at ${limit.percent}%${resets}` }]
224  })
225
226/** The /config effort row's value, read until the first request reports the effort it used. */
227export const configuredEffort = (rows: ConfigRow[]) => {
228  const row = rows.find(r => /effort/i.test(r.key))
229  return typeof row?.value === 'string' ? row.value : null
230}
231
232const newAgent = (id: string, now: number): AgentStat => ({
233  id,
234  description: '',
235  type: 'agent',
236  model: null,
237  effort: null,
238  status: 'running',
239  startedAt: now,
240  endedAt: null,
241  tokens: NO_TOKENS,
242  tools: 0,
243})
244
245/** Applies `patch` to the agent `id`, adding it first when the band has not seen it yet. */
246export const withAgent = (list: AgentStat[], id: string, now: number, patch: (a: AgentStat) => AgentStat) => {
247  const found = list.find(a => a.id === id)
248  if (found) return list.map(a => (a.id === id ? patch(a) : a))
249  return [...list, patch(newAgent(id, now))].slice(-KEEP)
250}
251
252export const settle = <T extends { status: RunStatus; endedAt: number | null }>(run: T, status: RunStatus, now: number): T => ({
253  ...run,
254  status,
255  endedAt: isActive(status) ? null : (run.endedAt ?? now),
256})
257
258/** The engine's roster folded in: names, types and statuses; one it no longer lists has ended. */
259export const mergeRoster = (list: AgentStat[], roster: AgentInfo[], now: number) => {
260  let next = list
261  for (const info of roster) {
262    next = withAgent(next, info.id, now, a =>
263      settle({ ...a, description: info.description || a.description, type: info.type }, runStatus(info.status), now),
264    )
265  }
266  const listed = new Set(roster.map(r => r.id))
267  return next.map(a => (isActive(a.status) && !listed.has(a.id) ? settle(a, 'completed', now) : a))
268}
269
270/** A subagent's model request: the model and effort it runs on, and it runs again. */
271export const agentStep = (list: AgentStat[], agentId: string, model: string, level: string | null, now: number) =>
272  withAgent(list, agentId, now, a => ({ ...settle(a, isActive(a.status) ? a.status : 'running', now), model, effort: level ?? a.effort }))
273
274export const agentTokens = (list: AgentStat[], agentId: string, used: Tokens, now: number) =>
275  withAgent(list, agentId, now, a => ({ ...a, tokens: addTokens(a.tokens, used) }))
276
277export const agentTool = (list: AgentStat[], agentId: string, now: number) =>
278  withAgent(list, agentId, now, a => ({ ...a, tools: a.tools + 1 }))
279
280/** One more call of `tool` in the session that started at `since`. */
281export const countTool = (counts: ToolCounts | null, tool: string, since: number): ToolCounts => {
282  const list = counts?.since === since ? counts.counts : []
283  const found = list.find(c => c.tool === tool)
284  return {
285    since,
286    counts: found ? list.map(c => (c === found ? { ...c, count: c.count + 1 } : c)) : [...list, { tool, count: 1, errors: 0 }],
287  }
288}
289
290/** One more failed call of `tool`, counted when its result came back as an error. */
291export const countError = (counts: ToolCounts | null, tool: string, since: number): ToolCounts | null =>
292  counts?.since === since ? { since, counts: counts.counts.map(c => (c.tool === tool ? { ...c, errors: (c.errors ?? 0) + 1 } : c)) } : counts
293
294export const startLive = (list: LiveTool[], call: LiveTool) => [...list.filter(t => t.id !== call.id), call].slice(-20)
295
296export const addShell = (list: ShellStat[], shell: ShellStat) => [...list.filter(s => s.id !== shell.id), shell].slice(-KEEP)
297
298/** Ends the run `id` (a shell or an agent) with `status`. */
299export const endRun = <T extends { id: string; status: RunStatus; endedAt: number | null }>(list: T[], id: string, status: RunStatus, now: number) =>
300  list.map(r => (r.id === id ? settle(r, status, now) : r))
301
302/** The task ids and statuses a task notification's text carries. */
303export const parseNotification = (text: string) => {
304  const ids = [...text.matchAll(/<task-id>([^<]+)<\/task-id>/g)].map(m => m[1]!.trim())
305  const statuses = [...text.matchAll(/<status>([^<]+)<\/status>/g)].map(m => m[1]!.trim())
306  return ids.map((id, i) => ({ id, status: notifiedStatus(statuses[i] ?? 'completed') }))
307}
308
309/** Whether the band has something moving: a tool in flight, a run going, or one leaving after it ended. */
310export const isBusy = (live: LiveTool[], agents: AgentStat[], shells: ShellStat[], now: number) =>
311  live.length > 0 || agents.some(a => isShown(a, now)) || shells.some(s => isShown(s, now))
312
hooks/forecast.ts 59 lines
1import type { Limit } from '../types'
2
3// Where each plan limit will be at its reset, at the pace you are spending it now.
4
5/** How long each window lasts, so its start is its reset less this. */
6const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
7
8/** The recent pace needs this much history; with less, the window's average pace stands in. */
9const RECENT_MIN_MS = 10 * 60_000
10/** How far back the recent pace looks. */
11export const RECENT_MS = 60 * 60_000
12
13export type Sample = { at: number; percent: number }
14
15export type Forecast = {
16  kind: string
17  percent: number
18  resetAt: number
19  /** When the limit reaches 100% at this pace, if that comes before the reset; else null. */
20  outAt: number | null
21  /** Where the limit will stand at the reset at this pace. */
22  atReset: number
23  /** Which pace the forecast uses: the last hour's, or the window's average. */
24  pace: 'recent' | 'average'
25}
26
27/**
28 * The forecast for one limit: the last hour's pace when there is at least ten minutes of it
29 * within this window, else the average since the window began. Null for a window of unknown
30 * length or with no reset time.
31 */
32export const forecast = (limit: Limit, samples: Sample[], now: number): Forecast | null => {
33  const length = WINDOW_MS[limit.kind]
34  if (length === undefined || limit.resetsAt === null) return null
35  const resetAt = Date.parse(limit.resetsAt)
36  if (!Number.isFinite(resetAt) || resetAt <= now) return null
37  const start = resetAt - length
38  const recent = samples.filter(s => s.at >= Math.max(start, now - RECENT_MS))
39  const first = recent[0]
40  const hasRecent = first !== undefined && now - first.at >= RECENT_MIN_MS && limit.percent >= first.percent
41  const perMs = hasRecent ? (limit.percent - first.percent) / (now - first.at) : now > start ? limit.percent / (now - start) : 0
42  const outAt = perMs > 0 ? now + (100 - limit.percent) / perMs : null
43  return {
44    kind: limit.kind,
45    percent: limit.percent,
46    resetAt,
47    outAt: outAt !== null && outAt < resetAt && limit.percent < 100 ? outAt : limit.percent >= 100 ? now : null,
48    atReset: Math.min(999, Math.round(limit.percent + perMs * (resetAt - now))),
49    pace: hasRecent ? 'recent' : 'average',
50  }
51}
52
53/** A sample more for `kind`, keeping only what the recent pace can use. */
54export const addSample = (samples: Sample[], sample: Sample) => {
55  const kept = samples.filter(s => s.at >= sample.at - RECENT_MS)
56  const last = kept[kept.length - 1]
57  return last !== undefined && last.percent === sample.percent ? kept : [...kept, sample]
58}
59
hooks/format.ts 149 lines
1import type { ModelUsage } from 'claude-code'
2
3import type { RunStatus, Tokens } from '../types'
4
5export const NO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
6
7export const toTokens = (u: ModelUsage): Tokens => ({
8  input: u.input_tokens,
9  output: u.output_tokens,
10  cacheRead: u.cache_read_input_tokens,
11  cacheWrite: u.cache_creation_input_tokens,
12})
13
14export const addTokens = (a: Tokens, b: Tokens): Tokens => ({
15  input: a.input + b.input,
16  output: a.output + b.output,
17  cacheRead: a.cacheRead + b.cacheRead,
18  cacheWrite: a.cacheWrite + b.cacheWrite,
19})
20
21/** Everything the requests sent and got back: uncached, cache read and written, output. */
22export const totalTokens = (t: Tokens) => t.input + t.cacheRead + t.cacheWrite + t.output
23
24// Share of the prompt the cache served: read over everything sent (uncached, written, read).
25export const hitRate = (t: Tokens) => {
26  const sent = t.input + t.cacheRead + t.cacheWrite
27  return sent === 0 ? null : Math.round((t.cacheRead / sent) * 100)
28}
29
30export const count = (n: number) =>
31  n >= 1_000_000_000
32    ? `${+(n / 1_000_000_000).toFixed(1)}B`
33    : n >= 1_000_000
34      ? `${+(n / 1_000_000).toFixed(1)}M`
35      : n >= 1000
36        ? `${+(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
37        : `${n}`
38
39/** A tool's short name: an MCP tool's own, without its server prefix (`mcp__srv__ctx_execute` → `ctx_execute`). */
40export const toolName = (tool: string) => (tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool)
41
42export const tone = (percent: number) => (percent >= 95 ? 'error' : percent >= 80 ? 'warning' : undefined)
43
44/** The server's grade of a usage row as a tone: `critical` red, `warning` amber, else none. */
45export const gradeTone = (severity: string | undefined) => (severity === 'critical' ? 'error' : severity === 'warning' ? 'warning' : undefined)
46
47/** A meter's tone: the graver of its own thresholds and the server's grade, which may know more. */
48export const meterTone = (percent: number, severity?: string) => {
49  const [own, server] = [tone(percent), gradeTone(severity)]
50  return own === 'error' || server === 'error' ? 'error' : own ?? server
51}
52
53const LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
54const SHORT_LABELS: Record<string, string> = { five_hour: '5H', seven_day: '7D' }
55
56export const limitLabel = (kind: string) => LABELS[kind] ?? kind.replace(/_/g, ' ')
57export const limitShortLabel = (kind: string) => SHORT_LABELS[kind] ?? kind.replace(/_/g, ' ').toUpperCase()
58
59// `claude-opus-5-5[1m]` → `Opus 5.5 1M`; a display name such as `Opus 5.5` passes unchanged.
60export const prettyModel = (id: string) => {
61  const isLongContext = /\[1m\]$/i.test(id)
62  const bare = id.replace(/\[1m\]$/i, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
63  const [family = '', ...rest] = bare.split('-')
64  if (family === '') return id
65  const version = rest.filter(p => /^\d+$/.test(p)).join('.')
66  const name = family[0]!.toUpperCase() + family.slice(1)
67  return `${name}${version ? ` ${version}` : ''}${isLongContext ? ' 1M' : ''}`
68}
69
70export const until = (iso: string, now: number) => {
71  const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60_000))
72  if (minutes < 60) return `${minutes}m`
73  if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
74  return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
75}
76
77export const elapsed = (ms: number) => {
78  const s = Math.max(0, Math.round(ms / 1000))
79  if (s < 60) return `${s}s`
80  if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
81  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
82}
83
84export const ago = (ms: number) => {
85  const minutes = Math.floor(ms / 60_000)
86  if (minutes < 1) return '<1m'
87  if (minutes < 60) return `${minutes}m`
88  if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
89  return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
90}
91
92const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
93
94/** The glyph and colour of a run's status; a running one spins with the band's tick. */
95export const statusMark = (status: RunStatus, tick: number): { glyph: string; color?: string } => {
96  if (status === 'running') return { glyph: SPINNER[tick % SPINNER.length]!, color: 'claude' }
97  if (status === 'waiting') return { glyph: '◷', color: 'suggestion' }
98  if (status === 'completed') return { glyph: '✓', color: 'success' }
99  if (status === 'failed') return { glyph: '✗', color: 'error' }
100  return { glyph: '■', color: 'warning' }
101}
102
103export const isActive = (status: RunStatus) => status === 'running' || status === 'waiting'
104
105/** How long a run that has ended stays in the band, its ✓ or ✗ on show, before it leaves. */
106export const LINGER_MS = 3000
107
108/** Whether the band still shows a run: while it runs, and for LINGER_MS after it ends. */
109export const isShown = (run: { status: RunStatus; endedAt: number | null }, now: number) =>
110  isActive(run.status) || (run.endedAt !== null && now - run.endedAt < LINGER_MS)
111
112/** The engine's agent statuses folded into the band's five. */
113export const runStatus = (status: string): RunStatus => {
114  if (status === 'completed' || status === 'failed' || status === 'killed' || status === 'waiting') return status
115  if (status === 'idle') return 'waiting'
116  return 'running'
117}
118
119/** A task notification's status word (`completed`, `failed`, `killed`, `stopped`, ...). */
120export const notifiedStatus = (status: string): RunStatus =>
121  status === 'completed' ? 'completed' : status === 'killed' || status === 'stopped' ? 'killed' : status === 'running' ? 'running' : 'failed'
122
123/** The last segment of a plugin-scoped agent type: `pr-review:code-reviewer` → `code-reviewer`. */
124export const shortType = (type: string) => type.split(':').pop() || type
125
126/** One line of a shell command, its whitespace folded. */
127export const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
128
129/** Dollars as a glance reads them: `$9.23`, `$118`, `$4.4k`. */
130export const money = (usd: number) =>
131  usd >= 1000 ? `$${+(usd / 1000).toFixed(1)}k` : usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
132
133/** An amount in its currency: dollars as `money` writes them, any other with its code (`12.50 EUR`). */
134export const cash = (amount: number, currency: string) => (currency === 'USD' ? money(amount) : `${amount.toFixed(2)} ${currency}`)
135
136const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
137
138/** The effort as pips out of five: `high` → `▰▰▰▱▱`; a numeric budget draws none. */
139export const effortPips = (level: string) => {
140  const i = LEVELS.indexOf(level)
141  return i < 0 ? '' : '▰'.repeat(i + 1) + '▱'.repeat(LEVELS.length - i - 1)
142}
143
144/** A change as an arrow and a signed percentage, or empty with nothing to compare against. */
145export const delta = (percent: number | null) =>
146  percent === null ? '' : percent > 0 ? `▲ +${percent}%` : percent < 0 ? `▼ ${percent}%` : '= 0%'
147
148export type Part ={ text: string; color?: string; emphasis?: 'warning' | 'strong' | 'plain' }
149
hooks/report.ts 169 lines
1import type { DayUsage, ModelDay } from '../types'
2
3// Usage history from `ccusage claude daily --json`: parsed defensively (it is another program's
4// output), then folded into today, this week and this month against the period before.
5
6const num = (o: object, key: string) => {
7  const v: unknown = Reflect.get(o, key)
8  return typeof v === 'number' && Number.isFinite(v) ? v : 0
9}
10
11const str = (o: object, key: string) => {
12  const v: unknown = Reflect.get(o, key)
13  return typeof v === 'string' ? v : null
14}
15
16const isObject = (v: unknown): v is object => typeof v === 'object' && v !== null
17
18const parseModels = (raw: unknown): ModelDay[] =>
19  Array.isArray(raw)
20    ? raw.filter(isObject).flatMap(m => {
21        const model = str(m, 'modelName')
22        if (model === null) return []
23        const tokens = num(m, 'inputTokens') + num(m, 'outputTokens') + num(m, 'cacheReadTokens') + num(m, 'cacheCreationTokens')
24        return [{ model, costUsd: num(m, 'cost'), tokens }]
25      })
26    : []
27
28/** The days of a ccusage daily report, or null when the text is not one. */
29export const parseDaily = (text: string): DayUsage[] | null => {
30  let data: unknown
31  try {
32    data = JSON.parse(text)
33  } catch {
34    return null
35  }
36  if (!isObject(data)) return null
37  const daily: unknown = Reflect.get(data, 'daily')
38  if (!Array.isArray(daily)) return null
39  return daily.filter(isObject).flatMap(d => {
40    const date = str(d, 'date') ?? str(d, 'period')
41    if (date === null || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return []
42    return [
43      {
44        date,
45        costUsd: num(d, 'totalCost'),
46        tokens: num(d, 'totalTokens'),
47        input: num(d, 'inputTokens'),
48        output: num(d, 'outputTokens'),
49        cacheRead: num(d, 'cacheReadTokens'),
50        cacheWrite: num(d, 'cacheCreationTokens'),
51        models: parseModels(Reflect.get(d, 'modelBreakdowns')),
52      },
53    ]
54  })
55}
56
57const pad = (n: number) => String(n).padStart(2, '0')
58
59/** The local calendar day of `ms`, as ccusage groups by default: `2026-10-08`. */
60export const localDate = (ms: number) => {
61  const d = new Date(ms)
62  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
63}
64
65/** `date` moved by `n` days, in calendar terms (noon keeps a DST shift off the date). */
66export const addDays = (date: string, n: number) => {
67  const [y = 0, m = 1, d = 1] = date.split('-').map(Number)
68  return localDate(new Date(y, m - 1, d + n, 12).getTime())
69}
70
71/** The Monday that starts `date`'s week. */
72export const weekStart = (date: string) => {
73  const [y = 0, m = 1, d = 1] = date.split('-').map(Number)
74  const weekday = (new Date(y, m - 1, d, 12).getDay() + 6) % 7
75  return addDays(date, -weekday)
76}
77
78/** The first day of `date`'s month, and of the month before. */
79export const monthStart = (date: string) => `${date.slice(0, 7)}-01`
80export const previousMonthStart = (date: string) => monthStart(addDays(monthStart(date), -1))
81
82/** Whole weeks the report shows, this one included. */
83export const REPORT_WEEKS = 6
84
85/** The ccusage `--since` that covers this month, the one before and the report's weeks: `20260831`. */
86export const historySince = (today: string) => {
87  const weeksBack = addDays(weekStart(today), -7 * (REPORT_WEEKS - 1))
88  const lastMonth = previousMonthStart(today)
89  return (weeksBack < lastMonth ? weeksBack : lastMonth).replace(/-/g, '')
90}
91
92export type Span = { costUsd: number; tokens: number; days: number }
93
94/** The days from `from` to `to`, both included. */
95export const span = (days: DayUsage[], from: string, to: string): Span => {
96  const inside = days.filter(d => d.date >= from && d.date <= to)
97  return {
98    costUsd: inside.reduce((s, d) => s + d.costUsd, 0),
99    tokens: inside.reduce((s, d) => s + d.tokens, 0),
100    days: inside.length,
101  }
102}
103
104/** Every model's share of the days from `from` to `to`, the costliest first. */
105export const modelShares = (days: DayUsage[], from: string, to: string) => {
106  const byModel = new Map<string, ModelDay>()
107  for (const day of days) {
108    if (day.date < from || day.date > to) continue
109    for (const m of day.models) {
110      const held = byModel.get(m.model)
111      byModel.set(m.model, held ? { model: m.model, costUsd: held.costUsd + m.costUsd, tokens: held.tokens + m.tokens } : m)
112    }
113  }
114  return [...byModel.values()].sort((a, b) => b.costUsd - a.costUsd)
115}
116
117export type Period = { name: string; now: Span; before: Span | null; from: string; to: string }
118
119/**
120 * Today against yesterday, this week against last week's same days, this month against last
121 * month's same days: the comparison is like for like however far into the period we are.
122 */
123/** The first day the history holds: Claude Code keeps transcripts for `cleanupPeriodDays` (30 by default). */
124export const firstDate = (days: DayUsage[]) => days.reduce<string | null>((first, d) => (first === null || d.date < first ? d.date : first), null)
125
126export const periods = (days: DayUsage[], today: string): Period[] => {
127  // A period that starts before the history does is not known, so it is not compared against.
128  const first = firstDate(days)
129  const known = (from: string, to: string) => (first === null || from < first ? null : span(days, from, to))
130  const week = weekStart(today)
131  const intoWeek = Math.round((Date.parse(today) - Date.parse(week)) / 86_400_000)
132  const month = monthStart(today)
133  const lastMonth = previousMonthStart(today)
134  const intoMonth = Number(today.slice(8, 10)) - 1
135  const lastMonthSame = addDays(lastMonth, intoMonth) < month ? addDays(lastMonth, intoMonth) : addDays(month, -1)
136  return [
137    { name: 'today', now: span(days, today, today), before: known(addDays(today, -1), addDays(today, -1)), from: today, to: today },
138    { name: 'week', now: span(days, week, today), before: known(addDays(week, -7), addDays(week, intoWeek - 7)), from: week, to: today },
139    { name: 'month', now: span(days, month, today), before: known(lastMonth, lastMonthSame), from: month, to: today },
140  ]
141}
142
143/** The cost of each of the last `n` days, oldest first, a missing day as zero. */
144export const dailyCosts = (days: DayUsage[], today: string, n: number) => {
145  const byDate = new Map(days.map(d => [d.date, d.costUsd]))
146  return Array.from({ length: n }, (_, i) => byDate.get(addDays(today, i - n + 1)) ?? 0)
147}
148
149/** The change from `before` to `now` as a whole percentage, or null when there is nothing to compare. */
150export const change = (now: number, before: number) => (before <= 0 ? null : Math.round(((now - before) / before) * 100))
151
152/** Whole weeks (Monday first) back from this one, newest first. */
153export const weekSpans = (days: DayUsage[], today: string, n: number) =>
154  Array.from({ length: n }, (_, i) => {
155    const from = addDays(weekStart(today), -7 * i)
156    const to = i === 0 ? today : addDays(from, 6)
157    return { from, to, ...span(days, from, to) }
158  })
159
160/** This month and the one before, newest first. */
161export const monthSpans = (days: DayUsage[], today: string) => {
162  const month = monthStart(today)
163  const lastMonth = previousMonthStart(today)
164  return [
165    { from: month, to: today, ...span(days, month, today) },
166    { from: lastMonth, to: addDays(month, -1), ...span(days, lastMonth, addDays(month, -1)) },
167  ]
168}
169
hooks/report-view.tsx 232 lines
1import type { HistoryProblem, PlanUsage, UsageHistory } from '../types'
2import { ago, cash, count, delta, limitLabel, meterTone, money, prettyModel, until } from './format'
3import { REPORT_WEEKS, addDays, change, firstDate, localDate, modelShares, monthSpans, periods, weekSpans } from './report'
4import { ACCENT, blocks, rule, table } from './ui'
5import type { Canvas, Cell, Column } from './ui'
6
7const FRAME_CELLS = 4
8const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
9const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
10
11const weekday = (date: string) => {
12  const [y = 0, m = 1, d = 1] = date.split('-').map(Number)
13  return WEEKDAYS[new Date(y, m - 1, d, 12).getDay()] ?? ''
14}
15
16/** A cost bar against the period's largest, as a cell of the BAR column. */
17const barCell = (value: number, top: number, cells: number): Cell => ({ text: blocks(top > 0 ? (value / top) * 100 : 0, cells).filled, color: ACCENT })
18
19const deltaCell = (now: number, before: number | undefined): Cell => {
20  const pct = before === undefined ? null : change(now, before)
21  return pct === null ? { text: '—', dim: true } : { text: delta(pct), color: pct > 0 ? 'warning' : 'success' }
22}
23
24const DAY_COLUMNS: Column[] = [
25  { title: 'DAY', width: 14 },
26  { title: 'COST', width: 7, align: 'right' },
27  { title: 'TOKENS', width: 6, align: 'right' },
28  { title: 'TOP MODEL', width: 12, priority: 1 },
29  { title: 'BAR', width: 10, grow: true },
30]
31
32const PERIOD_COLUMNS: Column[] = [
33  { title: 'PERIOD', width: 17 },
34  { title: 'COST', width: 7, align: 'right' },
35  { title: 'TOKENS', width: 6, align: 'right' },
36  { title: 'VS BEFORE', width: 9, align: 'right', priority: 1 },
37  { title: 'BAR', width: 10, grow: true },
38]
39
40const MODEL_COLUMNS: Column[] = [
41  { title: 'MODEL', width: 14 },
42  { title: 'COST', width: 7, align: 'right' },
43  { title: 'TOKENS', width: 6, align: 'right' },
44  { title: 'SHARE', width: 5, align: 'right' },
45  { title: 'BAR', width: 10, grow: true },
46]
47
48const PLAN_COLUMNS: Column[] = [
49  { title: 'LIMIT', width: 22 },
50  { title: 'USED', width: 6, align: 'right' },
51  { title: 'RESETS', width: 7, align: 'right' },
52  { title: 'NOTE', width: 22, priority: 1 },
53  { title: 'BAR', width: 10, grow: true },
54]
55
56const SHARE_COLUMNS: Column[] = [
57  { title: 'PRODUCT', width: 22 },
58  { title: 'SHARE', width: 6, align: 'right' },
59  { title: 'BAR', width: 10, grow: true },
60]
61
62/** A limit's bar against 100%, in the tone its meter in the band has. */
63const limitBar = (percent: number, severity: string | undefined, cells: number): Cell => ({
64  text: blocks(percent, cells).filled,
65  color: meterTone(percent, severity) ?? ACCENT,
66})
67
68/**
69 * The account's plan limits as its usage endpoint reports them, every session, machine and claude.ai
70 * chat counted: the 5-hour and weekly windows, each model's own weekly limit, usage credits, and the
71 * weekly limit's shares by product. `▸` marks the limit the server names as the one that counts now.
72 */
73const drawPlan = (canvas: Canvas, inner: number, now: number, plan: PlanUsage) => {
74  const { Box, Text } = canvas.els
75  const barCells = Math.max(6, inner - 22 - 6 - 7 - 22 - 3 * 4)
76  const resets = (iso: string | null): Cell => (iso === null ? { text: '—', dim: true } : { text: until(iso, now), dim: true })
77  const graded = (severity: string | undefined, isActive: boolean) =>
78    [isActive ? 'counts now' : '', severity !== undefined && severity !== 'normal' ? severity : ''].filter(t => t !== '').join(' · ')
79  const limitRows = plan.limits.map((l): Record<string, Cell> => ({
80    LIMIT: { text: `${l.isActive === true ? '▸ ' : ''}${limitLabel(l.kind)}${l.kind === 'seven_day' ? ' · all models' : ''}`, bold: true },
81    USED: { text: `${l.percent}%`, bold: true, color: meterTone(l.percent, l.severity) },
82    RESETS: resets(l.resetsAt),
83    NOTE: { text: graded(l.severity, l.isActive === true), color: meterTone(l.percent, l.severity) },
84    BAR: limitBar(l.percent, l.severity, barCells),
85  }))
86  const otherRows = plan.rows.map((r): Record<string, Cell> => ({
87    LIMIT: { text: `${r.isActive ? '▸ ' : ''}${r.kind.startsWith('weekly') ? 'Weekly · ' : ''}${r.label}`, bold: true },
88    USED: { text: `${r.percent}%`, bold: true, color: meterTone(r.percent, r.severity) },
89    RESETS: resets(r.resetsAt),
90    NOTE: { text: [r.ofWeekly === null ? '' : `up to ${r.ofWeekly}% of weekly`, graded(r.severity, r.isActive)].filter(t => t !== '').join(' · '), dim: true },
91    BAR: limitBar(r.percent, r.severity, barCells),
92  }))
93  const c = plan.credits
94  const creditRows: Record<string, Cell>[] =
95    c === null
96      ? []
97      : [
98          {
99            LIMIT: { text: 'Usage credits', bold: true },
100            USED: c.limit === null || c.limit <= 0 ? { text: '—', dim: true } : { text: `${Math.round((c.used / c.limit) * 100)}%`, bold: true },
101            RESETS: { text: 'monthly', dim: true },
102            NOTE: { text: `${cash(c.used, c.currency)}${c.limit === null ? ' · no cap' : ` of ${cash(c.limit, c.currency)}`}${c.isOn ? '' : ' · off'}`, dim: true },
103            BAR: c.limit === null || c.limit <= 0 ? { text: '' } : limitBar((c.used / c.limit) * 100, undefined, barCells),
104          },
105        ]
106  const b = plan.breakdown
107  const shareCells = Math.max(6, inner - 22 - 6 - 3 * 2)
108  const shareRows = (b?.rows ?? []).map((r): Record<string, Cell> => ({
109    PRODUCT: { text: r.name, bold: r.percent > 0, dim: r.percent === 0 },
110    SHARE: { text: `${r.percent}%`, dim: r.percent === 0 },
111    BAR: { text: blocks(r.percent, shareCells).filled, color: ACCENT },
112  }))
113  return (
114    <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
115      <Box flexDirection="row" justifyContent="space-between">
116        <Text bold color={ACCENT}>{'🎯 PLAN LIMITS  ·  your account, every session and claude.ai'}</Text>
117        <Text dimColor>{`/api/oauth/usage · ${ago(now - plan.at)} ago`}</Text>
118      </Box>
119      {table(canvas.els, PLAN_COLUMNS, [...limitRows, ...otherRows, ...creditRows], inner)}
120      {b !== null && rule(canvas.els, '🧭 THIS WEEK BY PRODUCT', `share of the weekly limit${b.asOf === null ? '' : ` · as of ${ago(now - Date.parse(b.asOf))} ago`}`, inner)}
121      {b !== null && table(canvas.els, SHARE_COLUMNS, shareRows, inner)}
122    </Box>
123  )
124}
125
126/**
127 * The usage report: the account's plan limits when there is a reading of them, then this machine's
128 * Claude Code spend from ccusage: the last 14 days, the last 6 weeks, this month and the last, and
129 * the models.
130 */
131export const drawReport = (
132  canvas: Canvas,
133  room: number,
134  now: number,
135  history: UsageHistory | null,
136  problem: HistoryProblem | null,
137  isReading: boolean,
138  plan: PlanUsage | null = null,
139) => {
140  const { Box, Text } = canvas.els
141  const inner = room - FRAME_CELLS
142  const planBox = plan === null ? null : drawPlan(canvas, inner, now, plan)
143  if (history === null) {
144    return (
145      <Box flexDirection="column">
146        {planBox}
147        <Text dimColor>{problem === null ? 'Reading usage history…' : `No usage history: ${problem.reason}`}</Text>
148      </Box>
149    )
150  }
151  const today = localDate(now)
152  // Days before the history's first one have no data at all: not zero spent, nothing known.
153  const first = firstDate(history.days) ?? today
154  const noData: Cell = { text: 'no data', dim: true }
155  const barCells = Math.max(6, inner - 14 - 7 - 6 - 12 - 3 * 4)
156  const byDate = new Map(history.days.map(d => [d.date, d]))
157  const dates = Array.from({ length: 14 }, (_, i) => addDays(today, -i))
158  const dayTop = Math.max(0, ...dates.map(d => byDate.get(d)?.costUsd ?? 0))
159  const dayRows = dates.map((date): Record<string, Cell> => {
160    const day = byDate.get(date)
161    const top = day?.models.slice().sort((a, b) => b.costUsd - a.costUsd)[0]
162    if (date < first) return { DAY: { text: `${weekday(date)} ${date.slice(5)}`, dim: true }, COST: noData }
163    return {
164      DAY: { text: `${weekday(date)} ${date.slice(5)}${date === today ? ' ◀' : ''}`, bold: date === today },
165      COST: { text: money(day?.costUsd ?? 0), dim: day === undefined },
166      TOKENS: { text: count(day?.tokens ?? 0), dim: true },
167      'TOP MODEL': { text: top ? prettyModel(top.model) : '—', dim: true },
168      BAR: barCell(day?.costUsd ?? 0, dayTop, barCells),
169    }
170  })
171  // The week and the month still running are set against the same days of the one before.
172  const [, thisWeek, thisMonth] = periods(history.days, today)
173  const weeks = weekSpans(history.days, today, REPORT_WEEKS)
174  const weekTop = Math.max(0, ...weeks.map(w => w.costUsd))
175  const weekRows = weeks.map((w, i): Record<string, Cell> => {
176    const name = i === 0 ? 'this week' : `week of ${w.from.slice(5)}`
177    if (w.to < first) return { PERIOD: { text: name, dim: true }, COST: noData }
178    const before = weeks[i + 1]
179    const beforeCost = i === 0 ? thisWeek?.before?.costUsd : before !== undefined && before.from >= first ? before.costUsd : undefined
180    return {
181      PERIOD: { text: `${name}${w.from < first ? ` (from ${first.slice(5)})` : ''}`, bold: i === 0 },
182      COST: { text: money(w.costUsd), bold: i === 0 },
183      TOKENS: { text: count(w.tokens), dim: true },
184      'VS BEFORE': w.from < first ? { text: '—', dim: true } : deltaCell(w.costUsd, beforeCost),
185      BAR: barCell(w.costUsd, weekTop, barCells),
186    }
187  })
188  const months = monthSpans(history.days, today)
189  const monthTop = Math.max(0, ...months.map(m => m.costUsd))
190  const monthRows = months.map((m, i): Record<string, Cell> => ({
191    PERIOD: {
192      text: `${MONTHS[Number(m.from.slice(5, 7)) - 1] ?? m.from} ${m.from.slice(0, 4)}${i === 0 ? ' (so far)' : m.from < first ? ` (from ${first.slice(5)})` : ''}`,
193      bold: i === 0,
194    },
195    COST: { text: money(m.costUsd), bold: i === 0 },
196    TOKENS: { text: count(m.tokens), dim: true },
197    'VS BEFORE': i === 0 ? deltaCell(m.costUsd, thisMonth?.before?.costUsd) : { text: '—', dim: true },
198    BAR: barCell(m.costUsd, monthTop, barCells),
199  }))
200  const month = months[0]
201  const models = month === undefined ? [] : modelShares(history.days, month.from, month.to)
202  const monthCost = month?.costUsd ?? 0
203  const modelRows = models.map((m): Record<string, Cell> => ({
204    MODEL: { text: prettyModel(m.model), bold: true },
205    COST: { text: money(m.costUsd) },
206    TOKENS: { text: count(m.tokens), dim: true },
207    SHARE: { text: `${monthCost > 0 ? Math.round((m.costUsd / monthCost) * 100) : 0}%` },
208    BAR: barCell(m.costUsd, models[0]?.costUsd ?? 0, barCells),
209  }))
210  return (
211    <Box flexDirection="column">
212      {planBox}
213      <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
214        <Box flexDirection="row" justifyContent="space-between">
215          <Text bold color={ACCENT}>{'📊 USAGE REPORT  ·  Claude Code on this machine'}</Text>
216          <Text dimColor>
217            {`ccusage · data from ${first.slice(5)} · ${isReading ? 'reading…' : `${ago(now - history.at)} ago`}${problem !== null && problem.at > history.at ? ' · ⚠ stale' : ''}`}
218          </Text>
219        </Box>
220        {rule(canvas.els, '📅 LAST 14 DAYS', '', inner)}
221        {table(canvas.els, DAY_COLUMNS, dayRows, inner)}
222        {rule(canvas.els, '🗓  WEEKS', 'Monday to Sunday · this week against the same days last week', inner)}
223        {table(canvas.els, PERIOD_COLUMNS, weekRows, inner)}
224        {rule(canvas.els, '🌙 MONTHS', 'this month against the same days last month', inner)}
225        {table(canvas.els, PERIOD_COLUMNS, monthRows, inner)}
226        {rule(canvas.els, '🧠 MODELS THIS MONTH', '', inner)}
227        {table(canvas.els, MODEL_COLUMNS, modelRows, inner)}
228      </Box>
229    </Box>
230  )
231}
232
hooks/ui.tsx 230 lines
1import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
2
3import { meterTone } from './format'
4import type { Part } from './format'
5
6// The band is raised on the terminal and the desktop only; every surface's table has Box and Text.
7export type Els = Elements[RenderSurface]
8
9/** What draws and the elements it draws with: bars are text in the terminal, SVG on the desktop. */
10export type Canvas = { els: Els; surface: RenderSurface }
11
12export type Cell = { text: string; color?: string; dim?: boolean; bold?: boolean }
13
14/**
15 * A table column: `width` in cells (the least, for the one that `grow`s), `priority` the order
16 * columns give way in when the band is narrow (lowest first; absent never gives way).
17 */
18export type Column = { title: string; width: number; align?: 'left' | 'right'; grow?: boolean; priority?: number }
19
20export const ACCENT = 'claude'
21const BAR_FILL = 'suggestion'
22const SEPARATOR = ' │ '
23
24// Symbols the terminal draws two cells wide although they sit below the emoji planes.
25const WIDE = new Set([0x23f3, 0x231b, 0x26a1, 0x26fd, 0x2705, 0x274c, 0x2b50])
26
27/** Cells a string takes in the terminal: emoji count two, joiners and selectors none. */
28export const cellWidth = (text: string) => {
29  let width = 0
30  for (const ch of text) {
31    const cp = ch.codePointAt(0) ?? 0
32    if (cp === 0xfe0f || cp === 0x200d) continue
33    width += cp >= 0x1f000 || WIDE.has(cp) ? 2 : 1
34  }
35  return width
36}
37
38const tableWidth = (columns: Column[]) => columns.reduce((sum, c) => sum + c.width, 0) + SEPARATOR.length * (columns.length - 1)
39
40/** Drops the lowest-priority columns until the table, separators included, fits `room` cells. */
41export const fitColumns = (columns: Column[], room: number) => {
42  let kept = columns
43  while (tableWidth(kept) > room) {
44    const droppable = kept.filter(c => c.priority !== undefined)
45    if (droppable.length === 0) break
46    const lowest = droppable.reduce((a, b) => ((a.priority ?? 0) <= (b.priority ?? 0) ? a : b))
47    kept = kept.filter(c => c !== lowest)
48  }
49  // The growing column takes what the others leave.
50  const spare = Math.max(0, room - tableWidth(kept))
51  return kept.map(c => (c.grow ? { ...c, width: c.width + spare } : c))
52}
53
54const cellBox = (els: Els, column: Column, cell: Cell) => {
55  const { Box, Text } = els
56  return (
57    <Box width={column.width} flexShrink={0} justifyContent={column.align === 'right' ? 'flex-end' : 'flex-start'}>
58      <Text wrap="truncate-end" color={cell.color} dimColor={cell.dim} bold={cell.bold}>
59        {cell.text}
60      </Text>
61    </Box>
62  )
63}
64
65const tableRow = (els: Els, columns: Column[], cells: (c: Column) => Cell) => {
66  const { Box, Text } = els
67  return (
68    <Box flexDirection="row">
69      {columns.map((c, i) => (
70        <Box flexDirection="row">
71          {i > 0 && <Text dimColor>{SEPARATOR}</Text>}
72          {cellBox(els, c, cells(c))}
73        </Box>
74      ))}
75    </Box>
76  )
77}
78
79/**
80 * A ruled table: a bold header, a `─┼─` rule under it and one row per entry, every column cut to
81 * its width. Cells are keyed by column title, so a column dropped for room drops its cells too.
82 */
83export const table = (els: Els, columns: Column[], rows: Record<string, Cell>[], room: number) => {
84  const { Box, Text } = els
85  const kept = fitColumns(columns, room)
86  return (
87    <Box flexDirection="column">
88      {tableRow(els, kept, c => ({ text: c.title, bold: true, dim: true }))}
89      <Box flexDirection="row">
90        {kept.map((c, i) => (
91          <Box flexDirection="row">
92            {i > 0 && <Text dimColor>{'─┼─'}</Text>}
93            <Box width={c.width} flexShrink={0}>
94              <Text dimColor wrap="truncate-end">{'─'.repeat(c.width)}</Text>
95            </Box>
96          </Box>
97        ))}
98      </Box>
99      {rows.map(row => tableRow(els, kept, c => row[c.title] ?? { text: '' }))}
100    </Box>
101  )
102}
103
104/**
105 * A section's own box: a rounded border `room` cells wide, the title (accent) and a dim note on its
106 * first row, the body under them laid out in `room - 4` cells.
107 */
108export const card = (els: Els, title: string, note: string, room: number, body: RenderChildren, borderColor = 'subtle') => {
109  const { Box, Text } = els
110  return (
111    <Box flexDirection="column" width={room} flexShrink={0} borderStyle="round" borderColor={borderColor} paddingX={1}>
112      <Box flexDirection="row" justifyContent="space-between">
113        <Text bold color={ACCENT}>{title}</Text>
114        {note !== '' && <Text dimColor wrap="truncate-end">{note}</Text>}
115      </Box>
116      {body}
117    </Box>
118  )
119}
120
121/** Cells a card leaves its body: the border and one cell of padding on each side. */
122export const CARD_CELLS = 4
123/** Rows a card adds around its body: the border and the title row. */
124export const CARD_ROWS = 3
125
126/** A section rule across the frame: `── 🔥 TOKENS ─────────── note ──`. */
127export const rule = (els: Els, title: string, note: string, room: number) => {
128  const { Box, Text } = els
129  const tail = note === '' ? '──' : ` ${note} ──`
130  const fill = Math.max(2, room - cellWidth(`── ${title} `) - cellWidth(tail))
131  return (
132    <Box flexDirection="row">
133      <Text dimColor>{'── '}</Text>
134      <Text bold color={ACCENT}>{title}</Text>
135      <Text dimColor wrap="truncate-end">{` ${'─'.repeat(fill)}${tail}`}</Text>
136    </Box>
137  )
138}
139
140/** Parts joined by dim middots; a warning part in the warning colour, a strong one bold. */
141export const parts = (els: Els, list: Part[]) => {
142  const { Box, Text } = els
143  return (
144    <Box flexDirection="row" flexWrap="wrap">
145      {list.map((p, i) => (
146        <Box flexDirection="row">
147          {i > 0 && <Text dimColor>{' · '}</Text>}
148          <Text
149            color={p.emphasis === 'warning' ? 'warning' : p.color}
150            bold={p.emphasis === 'strong'}
151            dimColor={p.emphasis === undefined && p.color === undefined}
152          >
153            {p.text}
154          </Text>
155        </Box>
156      ))}
157    </Box>
158  )
159}
160
161// Drawn as an image on the desktop, so it cannot follow the theme: a translucent track reads on both.
162const svgBar = (percent: number, width: number, color: string | undefined) => {
163  const fill = color === 'error' ? '#e5484d' : color === 'warning' ? '#e0a030' : '#2f7de1'
164  const filled = Math.round((Math.min(percent, 100) / 100) * width)
165  return (
166    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8">` +
167    `<rect width="${width}" height="8" rx="4" fill="#808080" fill-opacity="0.3"/>` +
168    `<rect width="${filled}" height="8" rx="4" fill="${fill}"/></svg>`
169  )
170}
171
172const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
173
174/** `cells` of bar at an eighth of a cell's resolution: whole blocks, one partial, the dim track. */
175export const blocks = (percent: number, cells: number) => {
176  const eighths = Math.round((Math.max(0, Math.min(percent, 100)) / 100) * cells * 8)
177  const whole = Math.floor(eighths / 8)
178  const partial = EIGHTHS[eighths % 8] ?? ''
179  return { filled: '█'.repeat(whole) + partial, track: '░'.repeat(Math.max(0, cells - whole - (partial ? 1 : 0))) }
180}
181
182/** A bar `cells` wide in the meter's tone: blocks in the terminal, an SVG bar on the desktop. */
183export const gauge = (canvas: Canvas, name: string, percent: number, cells: number, color = meterTone(percent)) => {
184  const { Box, Text } = canvas.els
185  if (canvas.surface === 'desktop' && 'Svg' in canvas.els) {
186    const { Svg } = canvas.els
187    const px = cells * 9
188    return <Svg source={svgBar(percent, px, color)} alt={`${name} ${percent}% used`} width={px} height={8} />
189  }
190  const bar = blocks(percent, cells)
191  return (
192    <Box flexDirection="row" flexShrink={0}>
193      {bar.filled !== '' && <Text color={color ?? BAR_FILL}>{bar.filled}</Text>}
194      {bar.track !== '' && <Text dimColor>{bar.track}</Text>}
195    </Box>
196  )
197}
198
199/** The cells a meter takes besides its bar: icon, label, percentage and detail. */
200export const meterChrome = (label: string, detail: string) => cellWidth(label) + 1 + 1 + 4 + (detail === '' ? 0 : 1 + cellWidth(detail))
201
202/**
203 * `⛽ CTX ███████▍░░░░  29% 289k/1M`: a label, a bar, the percentage and a dim detail, amber from
204 * 80% and red from 95%, or as the server grades it (`severity`) when that is graver.
205 */
206export const meter = (canvas: Canvas, label: string, percent: number | null, detail: string, cells: number, severity?: string) => {
207  const { Box, Text } = canvas.els
208  const color = percent === null ? undefined : meterTone(percent, severity)
209  return (
210    <Box flexDirection="row" flexShrink={0}>
211      <Text bold>{`${label} `}</Text>
212      {percent === null ? <Text dimColor>{'─'.repeat(cells)}</Text> : gauge(canvas, label, percent, cells, color)}
213      <Box width={5} justifyContent="flex-end" flexShrink={0}>
214        <Text bold color={color} dimColor={percent === null}>
215          {percent === null ? '—' : `${percent}%`}
216        </Text>
217      </Box>
218      {detail !== '' && <Text dimColor>{` ${detail}`}</Text>}
219    </Box>
220  )
221}
222
223const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
224
225/** A sparkline of `values`, each against the largest; a zero is the lowest bar. */
226export const sparkline = (values: number[]) => {
227  const top = Math.max(0, ...values)
228  return values.map(v => (top === 0 ? SPARKS[0] : SPARKS[Math.min(7, Math.floor((v / top) * 7.999))])).join('')
229}
230
hooks/sections.tsx 495 lines
1import type { RenderChildren } from 'claude-code'
2
3import type { AgentStat, Compactions, ContextPart, HistoryProblem, LiveTool, PlanUsage, ShellStat, Snapshot, ToolCounts, Totals, TurnStat, UsageHistory } from '../types'
4import {
5  NO_TOKENS,
6  addTokens,
7  ago,
8  count,
9  delta,
10  elapsed,
11  hitRate,
12  isActive,
13  isShown,
14  money,
15  oneLine,
16  prettyModel,
17  shortType,
18  statusMark,
19  toolName,
20  totalTokens,
21} from './format'
22import type { Part } from './format'
23import type { Forecast } from './forecast'
24import { change, dailyCosts, localDate, modelShares, periods } from './report'
25import { ACCENT, CARD_CELLS, CARD_ROWS, blocks, card, cellWidth, parts, sparkline, table } from './ui'
26import type { Canvas, Cell, Column } from './ui'
27
28export type BandInput = {
29  canvas: Canvas
30  /** Cells the drawing is laid out in: the site's columns. */
31  room: number
32  /** Rows the drawing may take before it would scroll. */
33  rows: number
34  isWorking: boolean
35  now: number
36  snap: Snapshot
37  turn: TurnStat | null
38  totals: Totals | null
39  compactions: Compactions | null
40  effort: string | null
41  agents: AgentStat[]
42  shells: ShellStat[]
43  live: LiveTool[]
44  tools: ToolCounts | null
45  history: UsageHistory | null
46  historyProblem: HistoryProblem | null
47  /** The account's usage as its usage endpoint last reported it; null with no Claude login. */
48  plan: PlanUsage | null
49  forecasts: Forecast[]
50  cacheTtlMs: number
51}
52
53/**
54 * A section: its own box when the rows allow (`fullRows` tall, box included), else one line.
55 * `room` is the width the box takes.
56 */
57export type Section = { full: () => RenderChildren; fullRows: number; mini: () => RenderChildren }
58
59/** A table's header and the rule under it. */
60const TABLE_HEAD = 2
61
62export const EFFORT_COLORS: Record<string, string> = { max: 'error', xhigh: 'warning', high: ACCENT, medium: 'suggestion', low: 'success' }
63
64export const effortCell = (level: string | null): Cell =>
65  level === null ? { text: '—', dim: true } : { text: level, color: EFFORT_COLORS[level] }
66const hitCell = (hit: number | null): Cell =>
67  hit === null ? { text: '—', dim: true } : { text: `${hit}%`, color: hit < 50 ? 'warning' : hit >= 80 ? 'success' : undefined }
68
69/** The spinner frame: the band redraws once a second while something runs. */
70export const frame = (input: BandInput) => Math.floor(input.now / 1000)
71
72/** Active runs first, then the newest; ended ones stay, dimmed, until newer ones push them out. */
73const newestFirst = <T extends { status: AgentStat['status']; startedAt: number }>(runs: T[]) =>
74  [...runs].sort((a, b) => Number(isActive(b.status)) - Number(isActive(a.status)) || b.startedAt - a.startedAt)
75
76const runNote = (running: number, all: number, hidden: number) =>
77  `${running} running · ${all - running} done${hidden > 0 ? ` · +${hidden} in /vitals pane` : ''}`
78
79/** A one-line section: a bold label, then its parts. */
80export const lineOf = (input: BandInput, label: string, list: Part[]) => {
81  const { Box, Text } = input.canvas.els
82  return (
83    <Box flexDirection="row">
84      <Box width={11} flexShrink={0}>
85        <Text bold color={ACCENT}>{label}</Text>
86      </Box>
87      {parts(input.canvas.els, list)}
88    </Box>
89  )
90}
91
92const TOKEN_COLUMNS: Column[] = [
93  { title: ' ', width: 7 },
94  { title: 'IN', width: 6, align: 'right' },
95  { title: 'OUT', width: 6, align: 'right' },
96  { title: 'CACHE R', width: 7, align: 'right' },
97  { title: 'CACHE W', width: 7, align: 'right' },
98  { title: 'HIT', width: 4, align: 'right' },
99  { title: 'TOTAL', width: 6, align: 'right', priority: 1 },
100  { title: 'NOTE', width: 8, grow: true, priority: 0 },
101]
102
103type TokenCounts = Record<'input' | 'output' | 'cacheRead' | 'cacheWrite', number>
104
105const tokenRow = (name: string, t: TokenCounts, note: Cell) => ({
106  ' ': { text: name, bold: true },
107  IN: { text: count(t.input) },
108  OUT: { text: count(t.output) },
109  'CACHE R': { text: count(t.cacheRead) },
110  'CACHE W': { text: count(t.cacheWrite) },
111  HIT: hitCell(hitRate(t)),
112  TOTAL: { text: count(totalTokens(t)), bold: true },
113  NOTE: note,
114})
115
116const tokenParts = (name: string, t: TokenCounts): Part[] => {
117  const hit = hitRate(t)
118  return [
119    { text: name, emphasis: 'strong' },
120    { text: `in ${count(t.input)} out ${count(t.output)}` },
121    { text: `R ${count(t.cacheRead)} W ${count(t.cacheWrite)}` },
122    ...(hit === null ? [] : [{ text: `hit ${hit}%`, color: hit < 50 ? 'warning' : 'success' }]),
123  ]
124}
125
126export const tokensSection = (input: BandInput, room: number): Section | null => {
127  const { turn, totals, snap } = input
128  const hasTurn = turn !== null && turn.tokens !== null && turn.at >= snap.startedAt
129  const hasTotals = totals !== null && totals.since === snap.startedAt
130  if (!hasTurn && !hasTotals) return null
131  const idleMs = turn === null ? 0 : Math.max(0, input.now - turn.at)
132  const isCold = hasTurn && !input.isWorking && idleMs >= input.cacheTtlMs
133  const cache = !hasTurn ? '' : isCold ? '🥶 cache cold' : `🧊 cache warm${input.isWorking ? '' : ` · expires in ${ago(input.cacheTtlMs - idleMs)}`}`
134  const rows: Record<string, Cell>[] = []
135  if (hasTurn && turn.tokens !== null) {
136    const note = input.isWorking ? elapsed(turn.durationMs) : `${elapsed(turn.durationMs)} · idle ${ago(idleMs)}`
137    rows.push(tokenRow('turn', turn.tokens, { text: note, color: isCold ? 'warning' : undefined, dim: !isCold }))
138  }
139  if (hasTotals) rows.push(tokenRow('session', totals.tokens, { text: `${totals.turns} turn${totals.turns === 1 ? '' : 's'}`, dim: true }))
140  return {
141    fullRows: CARD_ROWS + TABLE_HEAD + rows.length,
142    full: () => card(input.canvas.els, '🔥 TOKENS', cache, room, table(input.canvas.els, TOKEN_COLUMNS, rows, room - CARD_CELLS)),
143    mini: () =>
144      lineOf(input, '🔥 TOKENS', [
145        ...(hasTurn && turn.tokens !== null ? tokenParts('turn', turn.tokens) : []),
146        ...(hasTotals ? tokenParts('session', totals.tokens) : []),
147        ...(cache === '' ? [] : [{ text: cache, color: isCold ? 'warning' : undefined }]),
148      ]),
149  }
150}
151
152const AGENT_COLUMNS: Column[] = [
153  { title: ' ', width: 1 },
154  { title: 'AGENT', width: 12, grow: true },
155  { title: 'TYPE', width: 13, priority: 1 },
156  { title: 'MODEL', width: 11 },
157  { title: 'EFFORT', width: 6, priority: 3 },
158  { title: 'TOKENS', width: 6, align: 'right' },
159  { title: 'HIT', width: 4, align: 'right', priority: 2 },
160  { title: 'TOOLS', width: 5, align: 'right', priority: 0 },
161  { title: 'TIME', width: 6, align: 'right' },
162]
163
164const mainRow = (input: BandInput): Record<string, Cell> => {
165  const agentTokens = input.agents.reduce((sum, a) => addTokens(sum, a.tokens), NO_TOKENS)
166  const all = input.totals?.since === input.snap.startedAt ? input.totals.tokens : NO_TOKENS
167  const own = {
168    input: all.input - agentTokens.input,
169    output: all.output - agentTokens.output,
170    cacheRead: all.cacheRead - agentTokens.cacheRead,
171    cacheWrite: all.cacheWrite - agentTokens.cacheWrite,
172  }
173  const toolsAll = input.tools?.since === input.snap.startedAt ? input.tools.counts.reduce((s, c) => s + c.count, 0) : 0
174  const toolsAgents = input.agents.reduce((s, a) => s + a.tools, 0)
175  return {
176    ' ': { text: input.isWorking ? statusMark('running', frame(input)).glyph : '◆', color: ACCENT },
177    AGENT: { text: 'main', bold: true },
178    TYPE: { text: 'session', dim: true },
179    MODEL: { text: prettyModel(input.snap.model) },
180    EFFORT: effortCell(input.effort),
181    TOKENS: { text: count(Math.max(0, totalTokens(own))) },
182    HIT: hitCell(hitRate(own)),
183    TOOLS: { text: `${Math.max(0, toolsAll - toolsAgents)}`, dim: true },
184    TIME: { text: ago(input.now - input.snap.startedAt), dim: true },
185  }
186}
187
188const agentRow = (input: BandInput, a: AgentStat): Record<string, Cell> => {
189  const mark = statusMark(a.status, frame(input))
190  return {
191    ' ': { text: mark.glyph, color: mark.color },
192    AGENT: { text: a.description || shortType(a.type), dim: !isActive(a.status) },
193    TYPE: { text: shortType(a.type), dim: true },
194    MODEL: a.model === null ? { text: '…', dim: true } : { text: prettyModel(a.model) },
195    EFFORT: effortCell(a.effort),
196    TOKENS: { text: count(totalTokens(a.tokens)) },
197    HIT: hitCell(hitRate(a.tokens)),
198    TOOLS: { text: `${a.tools}`, dim: true },
199    TIME: { text: elapsed((a.endedAt ?? input.now) - a.startedAt), dim: !isActive(a.status) },
200  }
201}
202
203/** The main loop and every subagent: what each runs on and what it spent. */
204/** Which runs a table shows: the band only those still running, the pane every one this session. */
205export type RunScope = 'running' | 'all'
206
207/** The note over a runs table: how many run, and where the ended ones are when the band hides them. */
208const scopeNote = (scope: RunScope, running: number, all: number, shown: number, none: string) => {
209  const ended = all - running
210  if (scope === 'all') return all === 0 ? none : runNote(running, all, all - shown)
211  if (running === 0) return ended > 0 ? `none running · ${ended} done in /vitals pane` : none
212  return `${running} running${ended > 0 ? ` · ${ended} done in /vitals pane` : ''}`
213}
214
215export const agentsSection = (input: BandInput, room: number, limit: number, scope: RunScope): Section => {
216  const sorted = newestFirst(input.agents)
217  const running = sorted.filter(a => isActive(a.status))
218  const pool = scope === 'all' ? sorted : sorted.filter(r => isShown(r, input.now))
219  const shown = pool.slice(0, Math.max(running.length, Math.min(pool.length, limit)))
220  const note = scopeNote(scope, running.length, sorted.length, shown.length, 'no subagents yet')
221  return {
222    fullRows: CARD_ROWS + TABLE_HEAD + 1 + shown.length,
223    full: () =>
224      card(
225        input.canvas.els,
226        '🤖 AGENTS',
227        note,
228        room,
229        table(input.canvas.els, AGENT_COLUMNS, [mainRow(input), ...shown.map(a => agentRow(input, a))], room - CARD_CELLS),
230      ),
231    mini: () =>
232      lineOf(input, '🤖 AGENTS', [
233        ...(pool.length === 0 ? [{ text: note }] : []),
234        ...pool.slice(0, 4).map(a => {
235          const mark = statusMark(a.status, frame(input))
236          return {
237            text: `${mark.glyph} ${shortType(a.type)} ${a.model === null ? '' : prettyModel(a.model)}${a.effort ? ` ${a.effort}` : ''} ${count(totalTokens(a.tokens))}`,
238            color: isActive(a.status) ? 'text' : undefined,
239          }
240        }),
241        ...(pool.length > 4 ? [{ text: `+${pool.length - 4}` }] : []),
242      ]),
243  }
244}
245
246const TOOL_COLUMNS: Column[] = [
247  { title: ' ', width: 1 },
248  { title: 'TOOL', width: 12, grow: true },
249  { title: 'CALLS', width: 5, align: 'right' },
250  { title: 'ERR', width: 3, align: 'right', priority: 1 },
251  { title: 'USE', width: 10, priority: 0 },
252  { title: 'NOW', width: 16, priority: 2 },
253]
254
255/** Every tool the session called: calls, failures, share of use, and what runs this second. */
256export const toolsSection = (input: BandInput, room: number, limit: number): Section | null => {
257  const counts = input.tools?.since === input.snap.startedAt ? input.tools.counts : []
258  if (counts.length === 0 && input.live.length === 0) return null
259  const agentName = (id: string | null) => {
260    const agent = id === null ? undefined : input.agents.find(a => a.id === id)
261    return agent ? ` ‹${shortType(agent.type)}›` : ''
262  }
263  const runningOf = (tool: string) => input.live.filter(t => t.tool === tool).sort((a, b) => a.startedAt - b.startedAt)
264  const sorted = [...counts].sort((a, b) => runningOf(b.tool).length - runningOf(a.tool).length || b.count - a.count)
265  const shown = sorted.slice(0, Math.max(1, limit))
266  const top = Math.max(1, ...counts.map(c => c.count))
267  const calls = counts.reduce((s, c) => s + c.count, 0)
268  const errors = counts.reduce((s, c) => s + (c.errors ?? 0), 0)
269  const rows = shown.map((c): Record<string, Cell> => {
270    const runs = runningOf(c.tool)
271    const oldest = runs[0]
272    return {
273      ' ': oldest ? { text: statusMark('running', frame(input)).glyph, color: ACCENT } : { text: '·', dim: true },
274      TOOL: { text: toolName(c.tool), bold: oldest !== undefined },
275      CALLS: { text: `${c.count}` },
276      ERR: (c.errors ?? 0) > 0 ? { text: `${c.errors}`, color: 'warning' } : { text: '0', dim: true },
277      USE: { text: blocks((c.count / top) * 100, 10).filled, color: oldest ? ACCENT : 'suggestion' },
278      NOW: oldest
279        ? { text: `${elapsed(input.now - oldest.startedAt)}${runs.length > 1 ? ` ×${runs.length}` : ''}${agentName(oldest.agentId)}`, color: ACCENT }
280        : { text: '' },
281    }
282  })
283  const note = `${calls} calls · ${errors} error${errors === 1 ? '' : 's'}${input.live.length > 0 ? ` · ${input.live.length} running` : ''}`
284  return {
285    fullRows: CARD_ROWS + TABLE_HEAD + rows.length,
286    full: () => card(input.canvas.els, '🔧 TOOLS', note, room, table(input.canvas.els, TOOL_COLUMNS, rows, room - CARD_CELLS)),
287    mini: () =>
288      lineOf(input, '🔧 TOOLS', [
289        ...input.live.map(t => ({ text: `${statusMark('running', frame(input)).glyph} ${toolName(t.tool)} ${elapsed(input.now - t.startedAt)}`, color: ACCENT })),
290        ...sorted.slice(0, 6).map(c => ({ text: `${toolName(c.tool)} ${c.count}` })),
291      ]),
292  }
293}
294
295const SHELL_COLUMNS: Column[] = [
296  { title: ' ', width: 1 },
297  { title: 'SHELL', width: 9, priority: 1 },
298  { title: 'COMMAND', width: 16, grow: true },
299  { title: 'BY', width: 12, priority: 0 },
300  { title: 'STATUS', width: 9, priority: 2 },
301  { title: 'TIME', width: 6, align: 'right' },
302]
303
304export const shellsSection = (input: BandInput, room: number, limit: number, scope: RunScope): Section | null => {
305  const sorted = newestFirst(input.shells)
306  const running = sorted.filter(s => isActive(s.status))
307  const pool = scope === 'all' ? sorted : sorted.filter(r => isShown(r, input.now))
308  if (pool.length === 0) return null
309  const by = (agentId: string | null) => {
310    const agent = agentId === null ? undefined : input.agents.find(a => a.id === agentId)
311    return agent ? shortType(agent.type) : 'main'
312  }
313  const shown = pool.slice(0, Math.max(running.length, Math.min(pool.length, limit)))
314  const cells = shown.map((s): Record<string, Cell> => {
315    const mark = statusMark(s.status, frame(input))
316    return {
317      ' ': { text: mark.glyph, color: mark.color },
318      SHELL: { text: s.id, dim: true },
319      COMMAND: { text: `$ ${oneLine(s.description ?? s.command)}`, dim: !isActive(s.status) },
320      BY: { text: by(s.agentId), dim: true },
321      STATUS: { text: s.status, color: mark.color },
322      TIME: { text: elapsed((s.endedAt ?? input.now) - s.startedAt), dim: !isActive(s.status) },
323    }
324  })
325  const note = scopeNote(scope, running.length, sorted.length, shown.length, 'none')
326  return {
327    fullRows: CARD_ROWS + TABLE_HEAD + shown.length,
328    full: () => card(input.canvas.els, '🐚 SHELLS', note, room, table(input.canvas.els, SHELL_COLUMNS, cells, room - CARD_CELLS)),
329    mini: () =>
330      lineOf(input, '🐚 SHELLS', [
331        ...pool.slice(0, 3).map(s => {
332          const mark = statusMark(s.status, frame(input))
333          return {
334            text: `${mark.glyph} ${oneLine(s.description ?? s.command)} ${s.status} ${elapsed((s.endedAt ?? input.now) - s.startedAt)}`,
335            color: isActive(s.status) ? ACCENT : undefined,
336          }
337        }),
338        ...(pool.length > 3 ? [{ text: `+${pool.length - 3}` }] : []),
339      ]),
340  }
341}
342
343const USAGE_COLUMNS: Column[] = [
344  { title: 'PERIOD', width: 6 },
345  { title: 'COST', width: 7, align: 'right' },
346  { title: 'TOKENS', width: 7, align: 'right' },
347  { title: 'VS BEFORE', width: 9, align: 'right', priority: 2 },
348  { title: 'TOP MODELS', width: 10, grow: true, priority: 1 },
349]
350
351const deltaCell = (now: number, before: number): Cell => {
352  const pct = change(now, before)
353  return pct === null ? { text: '—', dim: true } : { text: delta(pct), color: pct > 0 ? 'warning' : 'success' }
354}
355
356/** Today, this week and this month across every session, with a 14-day sparkline. */
357export const usageSection = (input: BandInput, room: number): Section | null => {
358  const { Box, Text } = input.canvas.els
359  const { history, historyProblem: problem } = input
360  if (history === null) {
361    if (problem === null) return null
362    const line = () => lineOf(input, '📊 USAGE', [{ text: `no history: ${problem.reason}` }])
363    return { fullRows: 1, full: line, mini: line }
364  }
365  const today = localDate(input.now)
366  const spans = periods(history.days, today)
367  const rows = spans.map((p): Record<string, Cell> => {
368    const top = modelShares(history.days, p.from, p.to).slice(0, 2)
369    const share = (usd: number) => (p.now.costUsd > 0 ? Math.round((usd / p.now.costUsd) * 100) : 0)
370    return {
371      PERIOD: { text: p.name, bold: true },
372      COST: { text: money(p.now.costUsd), bold: true },
373      TOKENS: { text: count(p.now.tokens) },
374      'VS BEFORE': p.before === null ? { text: '—', dim: true } : deltaCell(p.now.costUsd, p.before.costUsd),
375      'TOP MODELS': { text: top.map(m => `${prettyModel(m.model)} ${share(m.costUsd)}%`).join(' · '), dim: true },
376    }
377  })
378  const costs = dailyCosts(history.days, today, 14)
379  const stale = problem !== null && problem.at > history.at ? '⚠ stale · ' : ''
380  return {
381    fullRows: CARD_ROWS + TABLE_HEAD + rows.length + 1,
382    full: () =>
383      card(
384        input.canvas.els,
385        '📊 USAGE',
386        `${stale}ccusage · ${ago(input.now - history.at)} ago`,
387        room,
388        <Box flexDirection="column">
389          {table(input.canvas.els, USAGE_COLUMNS, rows, room - CARD_CELLS)}
390          <Box flexDirection="row">
391            <Text dimColor>{'14 days  '}</Text>
392            <Text color={ACCENT}>{sparkline(costs)}</Text>
393            <Text dimColor>{`  peak ${money(Math.max(...costs))} · /vitals report`}</Text>
394          </Box>
395        </Box>,
396      ),
397    mini: () =>
398      lineOf(input, '📊 USAGE', [
399        ...spans.map(p => ({ text: `${p.name} ${money(p.now.costUsd)}`, emphasis: 'strong' as const })),
400        { text: sparkline(costs), color: ACCENT },
401      ]),
402  }
403}
404
405const LEGEND_GAP = 3
406
407/** Splits `cells` among parts by their tokens, the rounding spread so the bar fills exactly. */
408const shares = (tokens: number[], cells: number) => {
409  const total = tokens.reduce((s, t) => s + t, 0)
410  if (total <= 0) return tokens.map(() => 0)
411  const exact = tokens.map(t => (t / total) * cells)
412  const whole = exact.map(Math.floor)
413  let left = cells - whole.reduce((s, w) => s + w, 0)
414  const byRemainder = exact.map((e, i) => ({ i, r: e - Math.floor(e) })).sort((a, b) => b.r - a.r)
415  for (const { i } of byRemainder) {
416    if (left <= 0) break
417    whole[i] = (whole[i] ?? 0) + 1
418    left -= 1
419  }
420  return whole
421}
422
423const partGlyph = (kind: ContextPart['kind']) => (kind === 'free' ? '░' : kind === 'buffer' ? '▒' : '█')
424
425/** What fills the context, as /context breaks it down: one bar in its colours and a legend. */
426export const contextSection = (input: BandInput, room: number): Section | null => {
427  const partsOf = input.snap.contextParts
428  if (partsOf === null || partsOf.length === 0) return null
429  const { Box, Text } = input.canvas.els
430  const inner = room - CARD_CELLS
431  const total = partsOf.reduce((s, p) => s + p.tokens, 0)
432  const cells = shares(partsOf.map(p => p.tokens), inner)
433  const items = partsOf.map(p => ({
434    part: p,
435    text: `${p.name} ${count(p.tokens)} ${total > 0 ? Math.round((p.tokens / total) * 100) : 0}%`,
436  }))
437  // The legend packed into rows by width, so its height is known before it is drawn.
438  const legend: (typeof items)[] = [[]]
439  let width = 0
440  for (const item of items) {
441    const w = 2 + cellWidth(item.text) + LEGEND_GAP
442    const row = legend[legend.length - 1]
443    if (row !== undefined && row.length > 0 && width + w > inner) {
444      legend.push([item])
445      width = w
446    } else {
447      row?.push(item)
448      width += w
449    }
450  }
451  const used = partsOf.filter(p => p.kind === 'used').reduce((s, p) => s + p.tokens, 0)
452  const swatch = (p: ContextPart) => (p.kind === 'free' ? { dim: true } : { color: p.color })
453  return {
454    fullRows: CARD_ROWS + 1 + legend.length,
455    full: () =>
456      card(
457        input.canvas.els,
458        '🧩 CONTEXT',
459        `${count(used)} used of ${count(total)} · estimate`,
460        room,
461        <Box flexDirection="column">
462          <Box flexDirection="row">
463            {partsOf.map((p, i) =>
464              (cells[i] ?? 0) > 0 ? (
465                <Text color={swatch(p).color} dimColor={swatch(p).dim}>
466                  {partGlyph(p.kind).repeat(cells[i] ?? 0)}
467                </Text>
468              ) : null,
469            )}
470          </Box>
471          {legend.map(row => (
472            <Box flexDirection="row">
473              {row.map(({ part, text }) => (
474                <Box flexDirection="row" marginRight={LEGEND_GAP}>
475                  <Text color={swatch(part).color} dimColor={swatch(part).dim}>{`${partGlyph(part.kind)} `}</Text>
476                  <Text dimColor={part.kind !== 'used'}>{text}</Text>
477                </Box>
478              ))}
479            </Box>
480          ))}
481        </Box>,
482      ),
483    mini: () =>
484      lineOf(
485        input,
486        '🧩 CONTEXT',
487        [...partsOf]
488          .filter(p => p.kind === 'used')
489          .sort((a, b) => b.tokens - a.tokens)
490          .slice(0, 4)
491          .map(p => ({ text: `${p.name} ${count(p.tokens)}`, color: p.color })),
492      ),
493  }
494}
495
types/index.d.ts 170 lines
1/**
2 * A plan limit. `severity` and `isActive` are the server's, when its usage endpoint read the same
3 * window: its grade for the meter's colour (`normal`, `warning`, `critical`) and whether this is
4 * the limit a single-value indicator shows.
5 */
6export type Limit = { kind: string; percent: number; resetsAt: string | null; severity?: string; isActive?: boolean }
7
8export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
9
10export type Snapshot = {
11  at: number
12  startedAt: number
13  prompts: number
14  model: string
15  dir: string
16  branch: string | null
17  isWorktree: boolean
18  ahead: number
19  behind: number
20  changed: number
21  contextPercent: number | null
22  contextTokens: number | null
23  contextWindow: number
24  costUsd: number | null
25  limits: Limit[]
26  /** The token count auto-compaction runs at, or null when it is off or not read yet. */
27  autoCompactAt: number | null
28  /** What fills the context, as /context breaks it down (estimated); null until first read. */
29  contextParts: ContextPart[] | null
30}
31
32/** One row of the context's breakdown: its tokens and the theme colour /context draws it in. */
33export type ContextPart = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
34
35export type TurnStat = {
36  at: number
37  durationMs: number
38  model: string | null
39  tokens: Tokens | null
40}
41
42export type Totals = { since: number; turns: number; tokens: Tokens }
43
44export type Compactions = {
45  since: number
46  count: number
47  before: number | null
48  after: number | null
49  /** When the last one finished, and what started it (`auto`, `manual`, `plugin`). */
50  at: number | null
51  trigger: string | null
52}
53
54/** Where a subagent or a background shell stands, as the band draws it. */
55export type RunStatus = 'running' | 'waiting' | 'completed' | 'failed' | 'killed'
56
57export type AgentStat = {
58  id: string
59  description: string
60  type: string
61  model: string | null
62  effort: string | null
63  status: RunStatus
64  startedAt: number
65  endedAt: number | null
66  tokens: Tokens
67  tools: number
68}
69
70export type ShellStat = {
71  id: string
72  command: string
73  description: string | null
74  agentId: string | null
75  status: RunStatus
76  startedAt: number
77  endedAt: number | null
78}
79
80/** A tool call in flight: drawn live with its elapsed time. */
81export type LiveTool = { id: string; tool: string; agentId: string | null; startedAt: number }
82
83export type ToolCount = { tool: string; count: number; errors: number }
84
85export type ToolCounts = { since: number; counts: ToolCount[] }
86
87/** How much the band shows: the vitals box alone, plus context and agents, or everything. */
88export type View = 'low' | 'medium' | 'high'
89
90/** One model's share of a day, as ccusage prices it. */
91export type ModelDay = { model: string; costUsd: number; tokens: number }
92
93/** One local day of Claude Code usage across every session on this machine. */
94export type DayUsage = {
95  date: string
96  costUsd: number
97  tokens: number
98  input: number
99  output: number
100  cacheRead: number
101  cacheWrite: number
102  models: ModelDay[]
103}
104
105/** The daily history the usage section and the report pane read: when it was read and the days. */
106export type UsageHistory = { at: number; days: DayUsage[] }
107
108/** Why the history could not be read this time; the last good one stays drawn. */
109export type HistoryProblem = { at: number; reason: string }
110
111/**
112 * One of the server's usage rows beyond the 5-hour and weekly windows, as it sends them: a model's
113 * or a surface's own weekly limit (`weekly_scoped`), or a meter Vitals does not know yet.
114 */
115export type PlanRow = {
116  kind: string
117  /** The server's label for what the row is for (`Fable`), else its kind. */
118  label: string
119  percent: number
120  resetsAt: string | null
121  severity: string
122  isActive: boolean
123  /** The share of the weekly limit this row may use, when the server says (`50`). */
124  ofWeekly: number | null
125}
126
127/** Usage credits (extra usage), in the currency's major units: what they cover once a limit is hit. */
128export type Credits = {
129  /** Whether they cover sends now: off when turned off, or when the month's limit is spent. */
130  isOn: boolean
131  used: number
132  /** The month's limit; null for none. */
133  limit: number | null
134  currency: string
135}
136
137/** Who spent the weekly limit, by product (Claude Code, chats, Cowork, ...), as shares of it. */
138export type Breakdown = { asOf: string | null; rows: { name: string; percent: number }[] }
139
140/** The account's usage as its usage endpoint last reported it, and when. */
141export type PlanUsage = {
142  at: number
143  limits: Limit[]
144  rows: PlanRow[]
145  /** Null while usage credits are off. */
146  credits: Credits | null
147  breakdown: Breakdown | null
148}
149
150declare module 'claude-code' {
151  interface PluginState {
152    vitals: {
153      snap: Snapshot | null
154      warned: string[]
155      lastTurn: TurnStat | null
156      totals: Totals | null
157      compactions: Compactions | null
158      effort: string | null
159      view: View
160      agents: AgentStat[]
161      shells: ShellStat[]
162      live: LiveTool[]
163      tools: ToolCounts | null
164      history: UsageHistory | null
165      historyProblem: HistoryProblem | null
166      plan: PlanUsage | null
167    }
168  }
169}
170