SLOPSHOPPER

token-usage

A band above the prompt with context fill, the 5-hour and weekly windows, reset countdowns, a burn-rate forecast and cost. /tokens opens a detail pane; /tokens…

panebandguardcommandtoastmodel
v0.46.2MITupdated 2026-10-18Synthesizzz/claude-code-token-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage
│ ┃ Token usage ✕ › fix the failing auth test and add an audit log call │ ┃ ⢀⠤⠤⡀ CTX 49% │ ┃ ⡇49⢸ 97,400 / 200,000 ⏺ Read(src/auth.ts) │ ┃ ⠈⠒⠒⠁ ⎿ Read 6 lines │ ┃ ⢀⠤⠤⡀ 5H 31% ⏺ Update(src/auth.ts) │ ┃ ⡇31⢸ reset -- ⎿ Added 2 lines, removed 1 line │ ┃ ⠈⠒⠒⠁ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Last turn │ ┃ input 2,100 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ output 1,480 │ ┃ cache read 91,000 ✻ Worked for 42s · done 4:20 PM │ ┃ cache write 4,300 │ ┃ › /tokens │ ┃ Tokens ⎿ token-usage: Token usage pane opened. │ ┃ this session 98.9k │ ┃ last turn 98.9k │ ┃ today 98.9k │ ┃ last 7 days 98.9k │ ┃ ▁▁▁▁▁▁█ │ ┃ read from cache 93% of input │ ┃ │ ┃ Last 7 days by project │ ┃ app 98.9k │ ┃ │ ┃ Last 7 days by model ▣ client module ./ring.tsx CTX ████▉ 49% 5H ███▏ 31% -- $0.00 API today ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./ring.tsx CTX ████▉ 49% 5H ███▏ 31% -- $0.00 API today ⟨Claude Code's own drawing⟩
Pane · Token usage
⢀⠤⠤⡀ CTX 49% ⡇49⢸ 97,400 / 200,000 ⠈⠒⠒⠁ ⢀⠤⠤⡀ 5H 31% ⡇31⢸ reset -- ⠈⠒⠒⠁ Last turn input 2,100 output 1,480 cache read 91,000 cache write 4,300 Tokens this session 98.9k last turn 98.9k today 98.9k last 7 days 98.9k ▁▁▁▁▁▁█ read from cache 93% of input Last 7 days by project app 98.9k Last 7 days by model opus-5-5 98.9k Heaviest turns today time project tools tokens 08:53 app Bash×4 Write×2 Edit Grep Read 98.9k
Pane · tokens-style
Layout [ rings ] [ bars ] [ compact ] [ frames ] [ stack ] Palette Colour [ classic ] [ ocean ] [ sunset ] [ forest ] [ berry ] [ ice Grey [ mono ] Colour-blind safe [ safe ] [ okabe ] Preset [ classic ] [ timeline ] [ quiet ] [ neon ] [ split ] Labels [ auto ] [ short ] [ long ] [ caps ] [ thai ] Show [ context ] [ countdown ] [ forecast ] [ cost ] [ crab ] [ b Alerts [ on ] [ both ] [ app ] [ windows ] [ 60% ] [ test 5h ] [ te Outside the app [ popup ] [ privacy ] [ no quiet hours ] [ 22–8 ] [ 23–7 ] [ Data [ export ] [ old files ]
Pane · tokens-agents
No subagent has run yet in this session. When Claude starts one (Explore, Plan, ...), its card shows here, and the last turn that had some stays.
Pane · tokens-context
Nothing counted yet: run /tokens context.
README

token-usage

A mod for Claude Code that keeps your usage in view: a band above the prompt with the context fill, the 5-hour and weekly windows, a countdown to each reset, a burn-rate forecast and what the day has cost.

It is a plugin of function hooks. That API is early access and changes between Claude Code releases; this mod was written and tested against Claude Code 2.1.295 and 2.1.296 on Windows 11.

Install

In a terminal session of Claude Code, at the prompt:

/plugin install token-usage --marketplace Synthesizzz/claude-code-token-usage

Answer y to add the marketplace, then pick a scope. Installed at the user scope, the mod also loads in sessions the desktop app starts.

What it draws

WhereWhat you see
TerminalA row of the context, 5-hour and weekly figures with their percentages; countdowns, forecast and cost while there is room. Where the row has room left, each figure is a bar filled by eighths of a cell (10, 8 or 6 cells), and a window's bar lights the cell of the time gone, so a bar past it is spending faster than the clock; with no room, ring glyphs. With a second row to spare, what fills the context, the cache countdown and the word of what to do go beneath, where a narrow row still has them. The context glyph spins while a turn runs.
Desktop appThe same figures as vector drawings, in one of five layouts, fitted to the width of the window. A small crab brings each alert in a row of its own, and lives where the context ring would be: at a laptop while a turn runs, standing by between turns, asleep after two minutes.
/tokensA pane with a dial per figure, the reset and forecast for each window, the tokens of the last turn, and tokens counted: this session, the last turn, today, the last seven days as a chart, how much of the last turn's input was read from the cache, the last seven days by project, by model and by subagent, the month so far in dollars, and the heaviest turns of the day.

The band is drawn on the terminal and the desktop app. VS Code and the mobile app do not draw a band; /tokens still opens its pane there.

On the desktop the pane is a dashboard: the context and the windows as rings inside one another with their figures written large, the tips under them, this session, today and the last seven days as large counts above the week's chart, and the month so far in dollars under it (against its budget, where one is set), and where the week's tokens went as one bar by project, one by model and one by subagent. A terminal says the same in text, the rings in braille. The pictures' words take a dark ink on a light page and a light one on a dark page; each says what it shows in its alt text. The pictures take the pane's width; in a narrow pane the rings stand above their words and the counts go one under another.

Commands

Six words for every day:

TypeResult
/tokensOpens the usage pane
/tokens styleOpens a pane of buttons for most settings below. At its top the band is drawn in the look in force, from your own figures; the pointer resting on a layout or a palette shows the band in it, under the buttons, before you press. Palettes go by kind: colour, grey, colour-blind safe. Then the parts shown, the daily, weekly and monthly budgets (with an API key; a subscription bills no dollars, so its pane has none), alerts, the notification outside the app (popup, privacy, quiet hours), and export and old files
/tokens agentsOpens a pane of the subagents of the turn, a card each with a crab dressed for its job
/tokens contextOpens a pane of what fills the context, as /context breaks it down: the grid, its rows, the MCP servers, memory files, skills and agents
/tokens listSays what is chosen, how wide the band is, and what it left out
/tokens helpLists every word: these six, then the settings a line for each kind (look, parts, alerts, budgets, data), then the checks

Settings

Most have a button in /tokens style too; colour levels, the background, other projects' budgets and reset are typed.

TypeResult
/tokens classic · timeline · quiet · neon · splitSwitches to a preset
`/tokens layout rings\bars\compact\frames\stack`Changes the desktop layout (the layout's name alone works too)
`/tokens palette classic\ocean\sunset\mono\forest\berry\ice\ember\mint\safe\okabe`Changes the colours; every palette reads on a light theme as on a dark one, and safe and okabe are told apart with colour blindness
`/tokens color ok\warn\full #38bdf8`Sets one level's colour
`/tokens labels auto\short\long\caps\thai`Changes the words beside the figures
`/tokens show\hide context\countdown\forecast\cost\alerts\crab\breakdown`Shows or hides one part; breakdown is what fills the context in the band: the bar of its rows and the word of what to do
`/tokens alerts on\off`Switches alerts
`/tokens alerts both\app\windows`Where an alert shows
`/tokens alerts 60 on\off`Also alert at 60%
`/tokens alerts popup on\off`Whether news outside the app comes in the crab's own window (on to start with) or as a plain Windows notification
`/tokens alerts privacy on\off`Whether a Windows notification keeps commands and questions out (on to start with)
/tokens alerts quiet 22-8 · alerts quiet offSets hours when nothing goes out to Windows, or drops them
/tokens budget 10 · budget offSets a budget in dollars a day, or drops it
/tokens budget week 50 · budget week offSets a budget in dollars a week, Monday to Sunday, or drops it
/tokens budget month 200 · budget month offSets a budget in dollars a calendar month, or drops it
/tokens budget project here 5 · budget project shop offSets a budget in dollars a day for a project (here is the folder the session runs in), or drops it
/tokens exportWrites the last 31 days of every session to ~/.claude/token-usage/export-<day>.csv (a row a day: dollars, tokens) and .json (with tokens by project and by model)
/tokens tidy · tidy nowSays how many session files are over 31 days old and no longer read; now deletes them
/tokens resetBack to the defaults

The words from before 0.42.0 still work: /tokens context off (and <part> on|off for every part), /tokens popup off, /tokens privacy off and /tokens quiet 22-8. /tokens quiet alone is the preset.

Checks

TypeResult
/tokens alerts test · alerts test weekRaises a sample alert for the 5-hour or the weekly window, and says how it went
/tokens alerts test cache · alerts test lapsedBrings the cache warning now, for the context as it is (its buttons stand two minutes), or shows the band of a lapsed cache for a minute; their buttons do what they say, and the pointer on them opens their hints

A choice takes effect at once and is kept across sessions.

Layout, palette and labels apply to the desktop band. Showing or hiding a part applies on the terminal too.

Alerts

Each is raised once per window:

  • a window passes 85%, or reaches its limit
  • a window will be full before it resets, at the pace of the last half hour
  • a window that was near its limit resets
  • the context passes 80%; the band then shows a Compact button
  • a turn, while it runs, passes five times a usual turn (the median of the last twenty main turns, every session's, so a new session goes by the ones before it), and never under 300k tokens; with fewer than three turns to go by, at 500k. A subagent's tokens count toward the turn it runs in. Said once a turn, with why it is heavy, a bar of where its tokens went (the context re-read, read anew, output, subagents), and buttons for what fits, the best first, pressed with 1, 2, 3 even from an empty prompt: near the 5-hour limit, Stop now; subagents doing most of it, Keep going or See agents; a context near full, Compact after (the turn finishes, then it compacts); a lapsed cache, Keep going, Compact after or Warn at 2× (twice as high for the session), and no stop: the lapse was paid at the turn's first step, so stopping gets none of it back. Stop now ends the turn at once, as Esc does, and asks what next: Compact where the context is half full or more, Start fresh (/clear) where it is light, or Continue, which sends "Continue where you stopped." as your own message.
  • a window passes 60%, if you switch that on
  • the day's cost passes 80% of your budget, or all of it, if you set one
  • the week's cost, Monday to today, passes 80% of your weekly budget, or all of it, if you set one
  • the month's cost, its first day to today, passes 80% of your monthly budget, or all of it, if you set one
  • a project's cost today passes 80% of its own budget, or all of it, if you set one for it (each session counts what it spends under the folder it runs in)

The crab

In the app an alert is brought by a crab, in a row above the band, with the alert's words beside it. It peeks up out of the ground, looks left and right, pops up and sways on its feet; it leaves after eight seconds, or when you press OK. Its pose is the news:

AlertPose
past 60%waves a claw
past 85%wide eyes, a drop of sweat, a shiver
limit reachedbehind bars, shaking them now and then
filling too fastruns
window resetboth claws up, jumping
context past 80%squeezed between two walls
budget nearly spentwide eyes, a drop of sweat, a shiver
budget spentbehind bars

While a turn runs, the crab sits at a laptop where the context ring was, and code types out on the screen; the percentage stays beside it. This widens the band by about three and a half columns for as long as the turn runs, so a narrow window may drop its last part meanwhile.

When the turn ends the crab stands by in the same place: it breathes, looks ahead, then up at the figures beside it, blinks between, and taps its feet now and then. A session begun, or begun anew with Start fresh (/clear), starts with it standing by too. While it is out bringing news, or in quiet hours, the ring is there instead.

Two minutes with nothing to do, after a turn or since the session began, the crab falls asleep in the same place, breathing, with z's rising (up to half a minute later: the band's clock moves at least each half minute). The percentage stays beside it, standing or asleep, and the band is a column wider than with the ring. News wakes it, a new turn puts it back at the laptop, and in quiet hours it keeps the nap it has by the words. A terminal shows ᶻᶻ in place of the ring glyph meanwhile; standing by, it keeps the glyph.

With the crab on, the desktop band no longer draws the context ring: the percentage and the bar of what fills the context say how full it is. /tokens hide crab brings the ring back.

A terminal draws the crab beside the alert when the band has forty columns to spare: with six rows, in half blocks, two pixels a cell, its body in the crab's colour and its eyes dark, four rows tall; with five, in block characters, three rows, in the colour of its news. It does not move there, and there is no laptop. Narrower than forty columns, or with fewer rows, the alert's words and buttons stand without the crab.

The crab is drawn on your machine by the mod: it adds nothing to the conversation and costs no tokens. /tokens hide crab sends it away; alerts then show as before.

When the app is not in front

On Windows the mod also calls you back to Claude when it is minimised or behind another window, with a Windows notification that has the crab in it:

NewsPose
a task is done (every turn's end)beside a green tick sign
Claude asks you to choosescratching its head under a question mark
Claude needs leave to use a toolwaiting at a barrier
ten minutes after a task, still unansweredasleep
a usage alert, unless /tokens alerts app keeps alerts in the appthe alert's own pose

It stays quiet while the window in front is the Claude desktop app or the program the session runs in, and the reminder ten minutes on looks again at that moment. /tokens hide crab or /tokens alerts off silences it too: the mod writes ~/.claude/token-usage/notify.json, which the script reads.

  • Privacy, on to start with: a request for leave names only its tool (Bash), and a question says only to open Claude. With /tokens alerts privacy off the command or the question is shown, but what looks like a secret (a Bearer token, a key such as sk-… or ghp_…, a KEY=value, a --password=…) is put out of sight as ••• before the line is cut short.
  • The crab's own window, on to start with: where Windows takes notifications, the news comes in a small card of the mod's own that bounces out of the bottom-right corner, with the crab bobbing in it and confetti, for six seconds. It never takes the focus or shows in the task switcher; a click on it brings Claude to the front. The same notification goes quietly to the notification centre (Win+N), so it is still there later. Where Windows holds notifications back (Do not disturb, a presentation, a full-screen app) it is a plain notification, and so is any card that cannot be drawn. /tokens alerts popup off keeps to plain notifications.
  • Quiet hours (/tokens alerts quiet 22-8): nothing goes out to Windows from the first hour up to the last, across midnight too. Alerts still show in the app, and the band says quiet until 08:00 beside the crab asleep.
  • Sounds of your own: put a .wav for a kind of news in ~/.claude/token-usage/sounds/, named done.wav, ask.wav, allow.wav or wait.wav (still waiting), or an alert's pose (near.wav, jail.wav, pace.wav, back.wav, context.wav). It plays in place of the Windows sound, only when the notification shows; a kind with no file keeps the Windows sound. A notification from PowerShell cannot play a file itself, so the script mutes it and plays the file. Only .wav plays.

To be called back only when Claude needs you, keep the four kinds about the work and leave usage in the app, where the band and the crab show it:

  1. /tokens alerts app: usage alerts (context, limits, pace, resets, budget) stay in the app. Done, still waiting, a question and a request for leave still go to Windows: they come through the command hooks, not the alerts.
  2. In the desktop app, Settings → Claude Code: set Task complete, Question and Permission request notifications to Off, so each piece of news comes once, with the crab.

It is made of command hooks in hooks/hooks.json that run tools/notify/toast.ps1 with PowerShell (with -ExecutionPolicy Bypass for that one script), each in the background, so Claude never waits on them. The pictures are assets/crab/*.png, made by node tools/crab-art/make.mjs. For a try-out, TOKEN_USAGE_NOTIFY_DRY=1 prints what the script would do, TOKEN_USAGE_NOTIFY_FORCE=1 shows it even with the session in front (the ten-minute reminder a forced stop starts is forced too), TOKEN_USAGE_NOTIFY_FILE names a switch file in place of the mod's, TOKEN_USAGE_NOTIFY_STATE a folder for the log in place of the one below, TOKEN_USAGE_NOTIFY_SOUNDS a folder of sounds in place of yours, and TOKEN_USAGE_NOTIFY_POPUP=0 or =1 keeps to plain notifications or shows the crab's window even where Windows holds notifications back, whatever /tokens alerts popup says. Its log is %TEMP%\token-usage-notify\log.txt.

Windows shows nothing while notifications are off in Settings > System > Notifications. With Do not disturb on, a notification goes to the notification centre (Win+N) with no banner.

Where an alert shows

An alert shows in the app (by the crab on the desktop app, by the crab and a toast in a terminal, by a toast alone with the crab off) and, outside it, as a system notification. From the desktop app it goes to the notification hooks, where tools/notify/toast.ps1 raises it on Windows with the crab in the alert's pose; from a terminal on Windows it is a notification with a progress bar, raised through PowerShell; elsewhere it goes through Claude Code's own notification channel. Windows shows it only while notifications are on in Settings.

Cost and tokens

The band counts in dollars, the pane in tokens. The dollars are Claude Code's own estimate at API prices. On a subscription they are not a bill: they are what the same work would have cost by the token.

A session knows only its own running total, so each session keeps a ledger of its own, dollars a day, in ~/.claude/token-usage/sessions/<session>.json, and adds to it what each reading costs over the one before. The day's total is the sum of every session's file: each session writes only its own, so two that spend at the same moment both count. The band shows the day's total ($6.20 today).

  • The ledger starts the day the mod is installed, or updated to 0.10.0. Nothing earlier is known.
  • A session's first reading is where the mod came in, and adds nothing. From then on everything is counted.
  • A day is your local date. The ledger keeps thirty-one days. A session's file stays on your machine after the session ends, a few hundred bytes; one untouched for a month is no longer read, and /tokens tidy now deletes those (only files of that folder named as a session's id, never the running session's own; with PowerShell on Windows, rm elsewhere). What was counted before 0.16.0, when one ledger was shared by every session, is still read and still counts.
  • With a budget set (/tokens budget 10, or a button in /tokens style), a ring before the cost shows how much of it the day has used, and the crab says when 80% and all of it are spent, once a day each, whichever session notices first.

The pane counts tokens, which say more than dollars on a subscription: every token of a turn, input, output, cache read and cache write, a subagent's turns too. They are kept by day for every session, on your machine, for thirty-one days, from 0.14.0 on; from 0.16.0 each session keeps its own in its file beside its dollars, by day and by project.

  • Tokens: this session, the last turn, today, the last seven days as a chart, and the share of the last turn's input read from the cache. Counts are said short (950, 11.3k, 412k, 1.2M).
  • The last seven days by project, a project being the last name of the folder a session runs in, five at most, the most first.
  • The last seven days by the model that answered (opus-5-5, haiku-5-5), a subagent's turns under its own model, five at most.
  • When a session ends, a toast says what it counted and spent beside the average session of the week (This session: 412k tokens, $1.20, 1.4× your average session this week (300k).), and the next session's pane shows it as the last session. The toast is seen after /clear; on exit the window may already be gone.
  • Tips, two at most, from what the counts already say: the context past 70% (compact before a big task), a large turn that read less than half its input from the cache, a day at twice or more the average of the six before it.
  • The five heaviest turns of the day, across sessions, as a table: when each ended, its project, the tools it called and its tokens with its subagents'. A pane narrower than 38 columns leaves the tools out, and a long name is cut short. The tools are named short (Bash×2 Edit; an MCP tool by its last name, set_setting for mcp__ccd_settings__set_setting). A subagent's tools are not counted in the turn.

Context and resets

  • Compact turns into the primary button when the context, at the rate its last three turns climbed, fills within three more turns, even before it is past 80%. A fall in the context is taken as a compaction, and only the turns after it count. After a compaction the band shows the size it left at once, without waiting for the next turn: the count the compaction reports, else the free local count of the window.
  • The weekly window is forecast at the week's own pace: what it has used over the share of the week gone, from half a day in. Where that fills it before its reset, the pane, the band and an alert say when (full in 1d 12h). Half an hour of slope, which the 5-hour window goes by, says little about a week.
  • The pane gives each reset in clock time too (reset 2h14m, at 14:14, with the weekday where it is not today).
  • Past 85% of the 5-hour window, the band says when a big task can start (big task? after 15:20), in the place of the forecast.
  • A measurement that brings no reading of the windows (as at the start of a session) keeps the windows already shown, each until its reset passes.

What fills the context

/tokens context, or Details beside the line in /tokens, opens a pane of what the context holds, as /context counts it:

  • The grid of the window, square by square, in the app's own colours, beside its rows: each row's tokens and share, the buffer kept for compaction (▨) and the free space (□). In a pane under 44 columns the rows go beneath the grid.
  • MCP servers, each with its tools summed and named in the colour of the app it is for (Figma, Supabase, Linear, Slack and others; one the mod does not know takes a colour of its own, the same each time). A connector from claude.ai can come under an id in place of a name: the pane then names it by the app its tools are for (Vercel for create_deployment, Gmail for create_draft), or connector a1000001 where it cannot tell. Only the servers with tools in the window are listed; the rest are said in one line with what waits outside the window (+ 5 servers not loaded · 61.4k deferred): tools loaded only when searched for.
  • Memory files from your home folder (~/.claude/CLAUDE.md), the skills listed and how many, and the custom agents.

Under its figure the pane says the set-up, what the context holds before any message (every row but the messages), and how it has grown since the earliest day of the last week, with the row that moved most (Set-up 26.3k · was 22.7k on 10-09 (+3.6k: MCP tools +3.2k)). Each day's set-up is the latest counted that day by any session, kept thirty days.

In the band, what fills the context is drawn small. Under the context ring on the desktop, and after CTX in a terminal, a bar splits what is used into its rows, each in the pane's colour (8 cells on the desktop, 10 in a terminal). Beside the cost a word says what to do, only when there is something to do: past 80% how much the messages hold (messages 110k, beside Compact), or, where the set-up takes a quarter of the room the window leaves before its buffer, what is biggest in it (set-up 55.3k · System tools 34.2k), so a server or a skill not used can go. A press on the word opens this pane. A terminal with a second row puts them there, under the context's bar; on one row, the word goes in first, then the cache, then the bar; on the desktop the bar goes with the word "context". The band counts the context from the first turn, the free way; /tokens hide breakdown stops both.

The pane keeps up by itself: once it or /tokens has counted the context, the context is counted again, the free way, after every turn that moves it and after every compaction, so Refresh is needed only to count now. An exact count (Count exactly) stays until the next turn, then the free count takes its place.

It is counted the free way, estimated on your machine; Count exactly counts it with the token-count API, one request a tool and a memory file, as /context does. /tokens says the three rows that take the most in a line beneath the tips.

Subagents at work

/tokens agents opens a pane of the subagents of the turn, a card each: its agent type and the task it was given, the tools it called (Read×3 · Grep),

Source 28 files
hooks/register.tsx 758 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3import type { Costly, Notice, TurnTokens } from '../types'
4import {
5  EMPTY,
6  NO_SHARE,
7  NO_MODEL,
8  counted,
9  heavyLine,
10  heavyThreshold,
11  turnTokens,
12  keepTurn,
13  saneLedger,
14  saneDays,
15  saneProjects,
16  saneTurns,
17  shortTokens,
18  isPlanOf,
19  sessionSummary,
20} from './cost'
21import { cacheRates, judgePause, keepPauses, learnedTtl, sanePauses } from './cache'
22import { NO_PARTS, addStep, heavyAdvice, splitOf } from './advice'
23import type { TurnParts } from './advice'
24import { defaults, sane } from './style'
25import { drawBand } from './band'
26import { adopt, agentOf, countContext, exported, gather, keep, recount, tidy } from './ledger'
27import { compacted, measure, note, trouble } from './measure'
28import { SAMPLE, SAMPLE_WEEK, beginPause, bring, choose, compactIfAsked, endPause, forkFellShort, tell, testCache, testLapse } from './notices'
29import { drawAgentsPane, refreshCrew, watchCrew } from './pane-agents'
30import { drawContextPane } from './pane-context'
31import { joined, newTurn, spent, stepped, used } from './crew'
32import { drawStylePane } from './pane-style'
33import { drawUsagePane } from './pane-usage'
34import {
35  AGENTS_PANE,
36  CONTEXT_PANE,
37  FORK_SHORT_KEY,
38  GROWTH_KEPT,
39  LAST_SESSION_KEY,
40  LEDGER_KEY,
41  NO_PROJECT,
42  PANE,
43  PAUSES_KEY,
44  PROJECTS_KEY,
45  STYLE_KEY,
46  STYLE_PANE,
47  TICK_MS,
48  TOKEN_DAYS_KEY,
49  TURNS_KEY,
50  TYPICAL_KEPT,
51  TYPICAL_KEY,
52  isModel,
53  others,
54  saneLast,
55  saneTypical,
56  scope,
57  styleOf,
58  tick,
59} from './state'
60
61// Not `snapshot`: a session reloaded from 0.1.0 still holds that key in its old shape.
62const snapshot = atom({ plugin: 'token-usage', key: 'usage' } as const, null)
63
64const last = atom({ plugin: 'token-usage', key: 'last' } as const, null)
65
66// The style `/tokens` chose; null while the options' own stand.
67const chosen = atom({ plugin: 'token-usage', key: 'style' } as const, null)
68
69// Dollars by day, for every session, held here to draw; the store keeps what
70// of the day's budget was told, and the days counted before sessions had
71// files of their own.
72const ledger = atom({ plugin: 'token-usage', key: 'ledger' } as const, EMPTY)
73
74const lastSession = atom({ plugin: 'token-usage', key: 'lastSession' } as const, null)
75
76const mine = atom({ plugin: 'token-usage', key: 'mine' } as const, NO_SHARE)
77
78// What the store counted before sessions had files of their own.
79const before = atom({ plugin: 'token-usage', key: 'before' } as const, NO_SHARE)
80
81// Tokens by day, by project, and the heaviest turns of the day: shared by
82// every session, like the ledger. The pane counts in tokens; the band, in
83// dollars.
84const projects = atom({ plugin: 'token-usage', key: 'projects' } as const, {})
85
86const models = atom({ plugin: 'token-usage', key: 'models' } as const, {})
87
88const turns = atom({ plugin: 'token-usage', key: 'turns' } as const, [])
89
90const sessionTokens = atom({ plugin: 'token-usage', key: 'sessionTokens' } as const, 0)
91
92// The context fill at the end of each turn, for its forecast; this many kept.
93const growth = atom({ plugin: 'token-usage', key: 'growth' } as const, [])
94
95// The prompt cache: the pause it counts down, and the pauses it was judged
96// on, every session's (how long it lasts is the account's).
97const idleSince = atom({ plugin: 'token-usage', key: 'idleSince' } as const, null)
98
99// When the crab last had nothing to do: the band's crab naps two minutes on.
100const restSince = atom({ plugin: 'token-usage', key: 'restSince' } as const, null)
101
102// The model of the main thread's last response: what a cache read costs.
103const cacheModel = atom({ plugin: 'token-usage', key: 'cacheModel' } as const, null)
104
105// Whether a quiet Keep warm ping was seen not to hold the cache.
106const forkShort = atom({ plugin: 'token-usage', key: 'forkShort' } as const, false)
107
108// The subagents of the turn, by id, for the agents pane.
109const crew = atom({ plugin: 'token-usage', key: 'crew' } as const, {})
110
111// The main turn running now, and how far the heavy-turn line was raised.
112const turnNow = atom({ plugin: 'token-usage', key: 'turnNow' } as const, null)
113
114const heavyBoost = atom({ plugin: 'token-usage', key: 'heavyBoost' } as const, 1)
115
116// A compaction asked for while a turn ran waits this long after it ends.
117const COMPACT_AFTER_MS = 500
118
119const pauses = atom({ plugin: 'token-usage', key: 'pauses' } as const, [])
120
121// The tokens of the last main turns, every session's: what a usual turn is.
122// Kept in the store, so a new session goes by the ones before it.
123const typical = atom({ plugin: 'token-usage', key: 'typical' } as const, [])
124
125// Whether the last turn's tokens were all read.
126const turnRead = atom({ plugin: 'token-usage', key: 'turnRead' } as const, true)
127
128// How soon after a /clear the new session is set up.
129const CLEARED_MS = 500
130
131// What a session starts from: the look, the counts and the windows the store
132// kept, and a first measure. A /clear starts a session no session.start
133// comes for, so it is set up from session.end as well.
134async function setUp($: EngineInterface) {
135  const kept = sane(await $.store.get(STYLE_KEY))
136
137  if (kept !== null) {
138    await update($, chosen, () => kept)
139  }
140
141  await tell($, kept ?? scope.base)
142
143  const usage = await $.session.usage()
144  const days = saneLedger(await $.store.get(LEDGER_KEY))
145  const sums = saneProjects(await $.store.get(PROJECTS_KEY))
146  const heavy = saneTurns(await $.store.get(TURNS_KEY))
147  const tokens = saneDays(await $.store.get(TOKEN_DAYS_KEY))
148  const judged = sanePauses(await $.store.get(PAUSES_KEY).catch(() => undefined))
149  const usual = saneTypical(await $.store.get(TYPICAL_KEY).catch(() => undefined))
150
151  await update($, pauses, () => judged)
152  // Read apart: a store that cannot answer leaves Keep warm quiet.
153  const isForkShort = (await $.store.get(FORK_SHORT_KEY).catch(() => undefined)) === true
154
155  await update($, forkShort, () => isForkShort)
156  await update($, typical, () => usual)
157  const ended = await $.store.get(LAST_SESSION_KEY)
158
159  await update($, ledger, () => days)
160  await update($, turns, () => heavy)
161  await update($, lastSession, () => saneLast(ended))
162  // What the store counted before is kept apart, and no longer added to.
163  await update($, before, () => ({ days: days.days, tokens, projects: sums, models: {} }))
164  // A session started again goes on from its own file.
165  await adopt($)
166  await gather($, await $.clock.now())
167  await measure($, usage.context, usage.rateLimits, usage.cost, false, null)
168  // A session begun, or begun anew by a /clear, has the crab standing by.
169  const begun = await $.clock.now()
170
171  await update($, restSince, () => begun)
172}
173
174export const register: Register = (on, options) => {
175  const base = defaults(options)
176
177  // The band and the panes read the registration through the scope.
178  scope.base = base
179  let ticking: Timer | undefined
180  // The clock that reads the crew's list while a subagent runs.
181  const crewWatch: { ticker?: Timer } = {}
182  // The subagents of this session already counted, by id, with their type.
183  const seenAgents = new Map<string, string>()
184  // How the last notice outside the app went, for `/tokens list` to say.
185  let raising: string | undefined
186  // The folder the session runs in, by its last name, and the tools the
187  // turn running now has called.
188  let project = NO_PROJECT
189  let tools: Record<string, number> = {}
190  // The tokens of the turn running now, its subagents' with them.
191  let pending = 0
192  // The running turn's tokens, step by step, and whether it was said heavy.
193  let running = 0
194  let isHeavyTold = false
195  // Where the running turn's tokens went, for the heavy turn's notice.
196  let parts: TurnParts = NO_PARTS
197  // The main turn running now, and the one the crew was gathered in.
198  let mainTurn: string | null = null
199  let crewTurn: string | null = null
200
201  on('session.start', async ($, e, next) => {
202    scope.surface = e.surface ?? undefined
203    project = e.cwd.split(/[\\/]/).filter(part => part !== '').at(-1) ?? NO_PROJECT
204    scope.project = project
205    await $.command.register({
206      name: 'tokens',
207      description: 'Show token usage in a pane, or change how the band looks',
208      argumentHint:
209        '[help | style | agents | context | list | reset | <preset> | layout <name> | palette <name> | color <ok|warn|full> <#hex> | color background <#hex|off> | labels <name> | show|hide <part> | alerts <on|off|both|app|windows> | alerts popup|privacy on|off | alerts quiet <22-8|off> | budget <$|off> | budget week|month <$|off> | export | tidy [now]]',
210    })
211    await setUp($)
212    // A countdown moves while nothing is measured: the clock redraws it.
213    ticking?.cancel()
214    ticking = $.clock.every(TICK_MS, () => void tick($))
215
216    return next(e)
217  }).catch(async ($, e, next) => {
218    await note($, 'session.start', next.error)
219
220    return next.called ? undefined : next(e)
221  })
222
223  // A resumed session says how long since its last reply, how much it held,
224  // and what re-writing that would cost: the cache's pause counts from that
225  // reply, before any turn of the session runs.
226  on('classic.SessionStart', async ($, e, next) => {
227    const done = await next(e)
228
229    if ((e.source === 'resume' || e.source === 'fork') && typeof e.seconds_since_last_response === 'number') {
230      const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
231      const tokens = typeof e.context_tokens === 'number' && e.context_tokens > 0 ? e.context_tokens : null
232
233      await beginPause($, base, at, tokens, {
234        ...(typeof e.estimated_cache_write_usd === 'number' ? { usd: e.estimated_cache_write_usd } : {}),
235        ...(e.prompt_cache_likely_expired === true ? { lapsed: true } : {}),
236      })
237    }
238
239    return done
240  }).catch(async ($, e, next) => {
241    await note($, 'classic.SessionStart', next.error)
242
243    return next.called ? undefined : next(e)
244  })
245
246  on('session.measure', async ($, e, next) => {
247    const style = await styleOf($, base)
248    const notices = await measure(
249      $,
250      e.context,
251      e.rateLimits,
252      e.cost,
253      style.hasEarlyAlert,
254      style.hidden.includes('alerts') ? null : style.budget,
255      style.hidden.includes('alerts') ? null : style.weekBudget,
256      style.hidden.includes('alerts') ? {} : style.projectBudgets,
257      style.hidden.includes('alerts') ? null : style.monthBudget,
258    )
259
260    if (!style.hidden.includes('alerts')) {
261      raising = (await bring($, style, notices, scope.surface)) ?? raising
262    }
263
264    // What fills the context moved with it: the panes show it without Refresh.
265    if (e.changed.includes('context')) {
266      await recount($, !style.hidden.includes('breakdown'))
267    }
268
269    return next(e)
270  }).catch(async ($, e, next) => {
271    await note($, 'session.measure', next.error)
272
273    return next.called ? undefined : next(e)
274  })
275
276  // The main conversation compacted: the band shows the size it left now,
277  // not after the next turn. One ahead of time changes nothing yet.
278  on('session.compact', async ($, e, next) => {
279    const done = await next(e)
280
281    if (e.agentId === undefined && e.trigger !== 'precompute' && done.skip === undefined) {
282      await compacted($, done.tokensAfter)
283    }
284
285    return done
286  }).catch(async ($, e, next) => {
287    await note($, 'session.compact', next.error)
288
289    return next.called ? undefined : next(e)
290  })
291
292  // A session that ends says what it counted beside the week's sessions, and
293  // leaves it for the next one's pane. Exits stay fast: only what is held is
294  // read, and the other sessions as last read.
295  on('session.end', async ($, e, next) => {
296    // A /clear starts a conversation anew: its first turn reads no cache,
297    // whatever the time.
298    await update($, idleSince, () => null)
299    endPause()
300
301    try {
302      const at = await $.clock.now()
303      const isPlan = isPlanOf((await read($, snapshot))?.limits ?? [])
304      const line = sessionSummary(await read($, mine), [...others.values()].map(({ share }) => share), at, isPlan)
305
306      if (line !== null) {
307        const ended = { line, project, at }
308
309        $.ui.toast(line)
310        await update($, lastSession, () => ended)
311        await $.store.set(LAST_SESSION_KEY, ended)
312      }
313    } catch {
314      // Nothing to do: the session ends all the same.
315    }
316
317    const done = await next(e)
318
319    // A /clear goes on under a new session, which no session.start sets up.
320    if (e.reason === 'clear') {
321      $.clock.after(CLEARED_MS, () => void setUp($))
322    }
323
324    return done
325  }).catch(async ($, e, next) => {
326    await note($, 'session.end', next.error)
327
328    return next.called ? undefined : next(e)
329  })
330
331  // A turn far heavier than usual is said while it runs: each response's
332  // tokens, a subagent's too, go toward the turn, and past five times a usual
333  // turn the crab says so, once a turn. The response passes through as it
334  // came: nothing is read from it until it has all arrived.
335  on('turn.step', async function* ($, e, next) {
336    // The first request of a main turn ends a pause: what it reads from the
337    // cache says whether the cache outlasted the pause.
338    const isFirst = e.agentId === undefined && e.index === 0
339    const since = isFirst ? await read($, idleSince) : null
340    const startedAt = isFirst ? await $.clock.now() : 0
341
342    if (isFirst) {
343      await update($, idleSince, () => null)
344      await update($, restSince, () => null)
345      endPause()
346    }
347
348    // The stream beneath, forwarded as it comes; what it returns, at its end.
349    const result = yield* next(e)
350    const used = turnTokens(result?.usage)
351
352    if (e.agentId === undefined && isModel(result?.usage)) {
353      const model = result.usage.model
354
355      await update($, cacheModel, () => model)
356    }
357
358    if (since !== null && used !== null) {
359      const pause = judgePause(startedAt - since.at, since.tokens, used, startedAt)
360      const ttl = learnedTtl(await read($, pauses)).ttl
361
362      // A pause begun at a quiet ping teaches nothing of how long the cache
363      // lasts; lapsed short of that time, it says the ping did not hold it.
364      if (since.afterFork === true) {
365        if (pause !== null && !pause.isKept && ttl !== null && pause.gap < ttl) {
366          await forkFellShort($)
367        }
368      } else if (pause !== null) {
369        try {
370          const kept = keepPauses(sanePauses(await $.store.get(PAUSES_KEY)), pause)
371
372          await $.store.set(PAUSES_KEY, kept)
373          await update($, pauses, () => kept)
374        } catch {
375          await update($, pauses, held => keepPauses(held, pause))
376        }
377      }
378    }
379
380    if (used !== null) {
381      parts = addStep(parts, used, e.agentId !== undefined)
382    }
383
384    // A subagent's card counts each of its responses as it ends, and the
385    // context it read; its turn's own sum takes their place.
386    if (used !== null && e.agentId !== undefined) {
387      const subagent = e.agentId
388      const read = used.input + used.cacheRead + used.cacheWrite
389      const at = await $.clock.now()
390
391      await update($, crew, held => stepped(held, subagent, read + used.output, read, at))
392    }
393
394    if (used !== null && !isHeavyTold) {
395      running += used.input + used.output + used.cacheRead + used.cacheWrite
396
397      const past = await read($, typical)
398
399      if (running >= heavyThreshold(past) * (await read($, heavyBoost))) {
400        const style = await styleOf($, base)
401        const { title: said, text: usual } = heavyLine(running, past)
402        const seen = await read($, snapshot)
403        const advice = heavyAdvice(parts, {
404          five: seen?.limits.find(({ kind }) => kind.startsWith('five_hour'))?.percentUsed ?? null,
405          context: seen?.percent ?? null,
406          agentTypes: Object.values(await read($, crew)).map(({ type }) => type),
407          read: cacheRates(await read($, cacheModel), null).read,
408        })
409        const title = usual === 'Esc stops it.' ? said : `${said} · ${usual.replace(/\. Esc stops it\.$/, '')}`
410
411        isHeavyTold = true
412
413        if (!style.hidden.includes('alerts')) {
414          const notice: Notice = {
415            kind: 'near',
416            title,
417            text: advice.reason,
418            line: `${title}. ${advice.reason}`,
419            bar: { name: 'This turn', value: 1, label: shortTokens(running), status: advice.reason },
420            choices: advice.choices,
421            split: splitOf(parts),
422            tone: advice.tone,
423          }
424
425          raising = (await bring($, style, [notice], scope.surface)) ?? raising
426        }
427      }
428    }
429
430    return result
431  }).catch(async function* ($, e, next) {
432    await note($, 'turn.step', next.error)
433
434    // A response already read stands as it came; one not yet asked for is
435    // asked for as though this hook were not here.
436    if (!next.called) {
437      return yield* next(e)
438    }
439  })
440
441  // A subagent's tools are counted in its own turn, not the person's.
442  // The main loop's turn: its id is what a stop ends.
443  on('turn.start', async ($, e, next) => {
444    if (e.agentId === undefined) {
445      mainTurn = e.turnId
446      await update($, turnNow, () => e.turnId)
447    }
448
449    return next(e)
450  }).catch(async ($, e, next) => {
451    await note($, 'turn.start', next.error)
452
453    return next.called ? undefined : next(e)
454  })
455
456  on('agent.spawn', async ($, e, next) => {
457    const done = await next(e)
458
459    if ('agentId' in done && typeof done.agentId === 'string') {
460      const subagent = done.agentId
461      const at = await $.clock.now()
462
463      // The pane keeps the last turn that had subagents: the first spawn of a
464      // new turn lets the settled ones of the turn before go.
465      if (crewTurn !== mainTurn) {
466        crewTurn = mainTurn
467        await update($, crew, newTurn)
468      }
469
470      await update($, crew, held => joined(held, subagent, e.subagentType, e.description, at))
471      watchCrew($, crewWatch)
472    }
473
474    return done
475  }).catch(async ($, e, next) => {
476    await note($, 'agent.spawn', next.error)
477
478    return next.called ? undefined : next(e)
479  })
480
481  on('tool.call', async ($, e, next) => {
482    if (e.agentId === undefined) {
483      tools = { ...tools, [e.tool]: (tools[e.tool] ?? 0) + 1 }
484    } else {
485      const subagent = e.agentId
486      const at = await $.clock.now()
487
488      await update($, crew, held => used(held, subagent, e.tool, at))
489      watchCrew($, crewWatch)
490    }
491
492    return next(e)
493  }).catch(async ($, e, next) => {
494    await note($, 'tool.call', next.error)
495
496    return next.called ? undefined : next(e)
497  })
498
499  on('turn.complete', async ($, e, next) => {
500    // Read field by field: a field it cannot read counts nothing, and the
501    // band says so.
502    const usage = e.usage === undefined ? null : turnTokens(e.usage)
503
504    if (e.usage !== undefined) {
505      await update($, turnRead, () => usage?.isWhole ?? false)
506    }
507
508    // Every turn's tokens count, a subagent's too: by day, by project, for
509    // the session, and toward the turn it ran in.
510    if (usage !== null) {
511      const count = usage.input + usage.output + usage.cacheRead + usage.cacheWrite
512
513      await update($, sessionTokens, held => held + count)
514      pending += count
515
516      if (e.agentId !== undefined) {
517        const subagent = e.agentId
518        const at = await $.clock.now()
519
520        await update($, crew, held => spent(held, subagent, count, at))
521      }
522
523      // The count goes on the day and the project in this session's own
524      // share; a disk that refuses leaves the turn still taken in.
525      try {
526        const at = await $.clock.now()
527
528        await adopt($)
529        const model = isModel(e.usage) ? e.usage.model : NO_MODEL
530        // A subagent's turn goes under its agent type as well: the first of
531        // its turns is a run.
532        const agent = e.agentId === undefined ? undefined : await agentOf($, e.agentId, seenAgents)
533
534        await update($, mine, held => counted(held, at, project, count, model, agent))
535        await keep($)
536        await gather($, at)
537      } catch {
538        // Nothing to do: the share is held, and the next turn writes it.
539      }
540    }
541
542    if (e.agentId === undefined) {
543      const called = tools
544      const heft = pending
545      const fill = (await read($, snapshot))?.percent ?? null
546
547      tools = {}
548      pending = 0
549      running = 0
550      isHeavyTold = false
551      parts = NO_PARTS
552      // A compaction asked for while it ran, now that it has ended.
553      $.clock.after(COMPACT_AFTER_MS, () => void compactIfAsked($))
554
555      // The pause begins: the band counts the cache down, and the crab warns.
556      const endedAt = await $.clock.now()
557
558      await beginPause($, base, endedAt, (await read($, snapshot))?.tokens ?? null)
559      await update($, restSince, () => endedAt)
560
561      if (heft > 0) {
562        // Read afresh: another session may have added its own meanwhile.
563        try {
564          const kept = [...saneTypical(await $.store.get(TYPICAL_KEY)), heft].slice(-TYPICAL_KEPT)
565
566          await $.store.set(TYPICAL_KEY, kept)
567          await update($, typical, () => kept)
568        } catch {
569          await update($, typical, held => [...held, heft].slice(-TYPICAL_KEPT))
570        }
571      }
572
573      if (fill !== null) {
574        await update($, growth, held => [...held, fill].slice(-GROWTH_KEPT))
575      }
576
577      if (heft > 0) {
578        try {
579          const at = await $.clock.now()
580          const entry: Costly = { at, tokens: heft, project, tools: called }
581          const kept = keepTurn(saneTurns(await $.store.get(TURNS_KEY)), entry, at)
582
583          await $.store.set(TURNS_KEY, kept)
584          await update($, turns, () => kept)
585        } catch {
586          // As above: the turn is still taken in.
587        }
588      }
589    }
590
591    if (usage !== null && e.agentId === undefined) {
592      const turn: TurnTokens = {
593        input: usage.input,
594        output: usage.output,
595        cacheRead: usage.cacheRead,
596        cacheWrite: usage.cacheWrite,
597      }
598
599      await update($, last, () => turn)
600    }
601
602    return next(e)
603  }).catch(async ($, e, next) => {
604    await note($, 'turn.complete', next.error)
605
606    return next.called ? undefined : next(e)
607  })
608
609  on('command.run', { command: 'tokens' }, async ($, e) => {
610    const outcome = await choose($, base, e.args)
611
612    if ('export' in outcome) {
613      return { text: await exported($) }
614    }
615
616    if ('tidy' in outcome) {
617      return { text: await tidy($, outcome.tidy) }
618    }
619
620    if (!('open' in outcome)) {
621      if ('isCache' in outcome) {
622        await testCache($, base, learnedTtl(await read($, pauses)).ttl)
623      } else if ('isLapsed' in outcome) {
624        await testLapse($)
625      } else if ('isTest' in outcome) {
626        const sample = 'isWeek' in outcome ? SAMPLE_WEEK : SAMPLE
627
628        raising = (await bring($, await styleOf($, base), [sample], scope.surface)) ?? raising
629      }
630
631      const hasOutside = (await styleOf($, base)).notify !== 'app'
632      // The cache's tests stay in the app: nothing went outside it.
633      const extra =
634        'isTest' in outcome && !('isCache' in outcome) && !('isLapsed' in outcome)
635          ? hasOutside && raising !== undefined
636            ? [`Last notification outside the app: ${raising}.`]
637            : []
638          : 'isList' in outcome
639          ? [
640              scope.fitting,
641              ...(await trouble($, await $.clock.now())),
642              raising === undefined ? undefined : `Last notification outside the app: ${raising}.`,
643            ].filter(line => line !== undefined)
644          : []
645
646      return { text: [outcome.text, ...extra].join(' ') }
647    }
648
649    if (outcome.open === 'context') {
650      await countContext($, 'summary')
651
652      const opened = await $.ui.open({ id: CONTEXT_PANE, title: 'Token usage · context' })
653
654      return {
655        text: opened.isPlaced ? 'Token usage context pane opened.' : `Token usage context pane is waiting: ${opened.reason}`,
656      }
657    }
658
659    if (outcome.open === 'agents') {
660      if (await refreshCrew($)) {
661        watchCrew($, crewWatch)
662      }
663
664      const opened = await $.ui.open({ id: AGENTS_PANE, title: 'Token usage · agents' })
665
666      return {
667        text: opened.isPlaced ? 'Token usage agents pane opened.' : `Token usage agents pane is waiting: ${opened.reason}`,
668      }
669    }
670
671    if (outcome.open === 'style') {
672      const opened = await $.ui.open({
673        id: STYLE_PANE,
674        title: 'Token usage style',
675        focus: true,
676      })
677
678      return {
679        text: opened.isPlaced
680          ? 'Token usage style pane opened.'
681          : `Token usage style pane is waiting: ${opened.reason}`,
682      }
683    }
684
685    // The pane says in a line what fills the context: counted the free way.
686    const usage = (await countContext($, 'summary')) ?? (await $.session.usage())
687    await measure($, usage.context, usage.rateLimits, usage.cost, false, null)
688    const opened = await $.ui.open({ id: PANE, title: 'Token usage' })
689
690    return {
691      text: opened.isPlaced
692        ? 'Token usage pane opened.'
693        : `Token usage pane is waiting: ${opened.reason}`,
694    }
695  }).catch(async ($, e, next) => {
696    await note($, 'command.run', next.error)
697
698    return { text: `token-usage could not do that: ${next.error.message ?? next.error.kind}` }
699  })
700
701  on('ui.render', { component: 'AbovePrompt' }, drawBand).catch(async ($, e, next) => {
702    await note($, 'band', next.error)
703
704    const beneath = await next(e)
705
706    try {
707      const { Box, Text } = $.ui.resolve(e)
708
709      return (
710        <Box flexDirection="column">
711          <Text dimColor>token-usage could not draw the band: {next.error.message ?? next.error.kind}</Text>
712          {beneath}
713        </Box>
714      )
715    } catch {
716      return beneath
717    }
718  })
719
720  // The picker: every choice a button, the one in force drawn as the primary.
721  // A press goes the way the typed word does.
722  on('ui.render', { component: 'Pane', requestId: STYLE_PANE }, drawStylePane).catch(async ($, e, next) => {
723    await note($, 'style pane', next.error)
724
725    const { Text } = $.ui.resolve(e)
726
727    return <Text dimColor>token-usage could not draw this pane: {next.error.message ?? next.error.kind}</Text>
728  })
729
730  // What fills the context, as /context draws it: the grid in the app's own
731  // colours beside its rows, then the MCP servers in their apps' colours, the
732  // memory files, the skills and the agents. Counted the free way; a press
733  // counts it exactly with the token-count API.
734  on('ui.render', { component: 'Pane', requestId: AGENTS_PANE }, drawAgentsPane).catch(async ($, e, next) => {
735    await note($, 'agents pane', next.error)
736
737    const { Text } = $.ui.resolve(e)
738
739    return <Text dimColor>token-usage could not draw this pane: {next.error.message ?? next.error.kind}</Text>
740  })
741
742  on('ui.render', { component: 'Pane', requestId: CONTEXT_PANE }, drawContextPane).catch(async ($, e, next) => {
743    await note($, 'context pane', next.error)
744
745    const { Text } = $.ui.resolve(e)
746
747    return <Text dimColor>token-usage could not draw this pane: {next.error.message ?? next.error.kind}</Text>
748  })
749
750  on('ui.render', { component: 'Pane', requestId: PANE }, drawUsagePane).catch(async ($, e, next) => {
751    await note($, 'pane', next.error)
752
753    const { Text } = $.ui.resolve(e)
754
755    return <Text dimColor>token-usage could not draw this pane: {next.error.message ?? next.error.kind}</Text>
756  })
757}
758
hooks/cost.ts 701 lines
1import type { Costly, Ledger, Notice, Projects, Share, Tone, TurnTokens } from '../types'
2
3// What the days have cost. A session knows only its own running total, so
4// each session keeps a share of its own, dollars a day, and adds to it what
5// each reading costs over the one before; the day is the sum of every
6// session's share. The dollars are Claude Code's own estimate at API prices:
7// on a subscription they are not a bill.
8
9const DAY_MS = 24 * 60 * 60_000
10// Days kept in the ledger; the pane draws the last seven.
11// A month's days at the most, so the month so far is always whole.
12const KEPT_DAYS = 31
13const WEEK_DAYS = 7
14const BUDGET_LEVELS = [80, 100]
15const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
16const SPARKS = '▁▂▃▄▅▆▇█'
17const BUDGET = "Today's budget"
18// Projects the pane names, and turns of the day it keeps.
19const TOP = 5
20
21export const EMPTY: Ledger = { days: {}, told: { day: '', levels: [] } }
22
23export const usd = (amount: number) => `$${amount.toFixed(2)}`
24
25const pad = (value: number) => String(value).padStart(2, '0')
26const named = (date: Date) =>
27  `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
28
29// A day is the person's own: the local date, not the one in Greenwich.
30export const dayOf = (at: number) => named(new Date(at))
31
32// A ledger read back from the store, whatever was kept there.
33export const saneLedger = (value: unknown): Ledger => {
34  const { days, told } = (typeof value === 'object' && value !== null ? value : {}) as Record<
35    string,
36    unknown
37  >
38  const { day, levels } = (typeof told === 'object' && told !== null ? told : {}) as Record<
39    string,
40    unknown
41  >
42
43  return {
44    days: Object.fromEntries(
45      Object.entries(typeof days === 'object' && days !== null ? days : {}).filter(
46        (entry): entry is [string, number] =>
47          typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0,
48      ),
49    ),
50    told: {
51      day: typeof day === 'string' ? day : '',
52      levels: Array.isArray(levels) ? BUDGET_LEVELS.filter(level => levels.includes(level)) : [],
53    },
54  }
55}
56
57const isSum = (value: unknown): value is number =>
58  typeof value === 'number' && Number.isFinite(value) && value >= 0
59const isRecord = (value: unknown): value is Record<string, unknown> =>
60  typeof value === 'object' && value !== null && !Array.isArray(value)
61// To a hundredth of a cent: sums of fractions drift otherwise.
62const cents = (amount: number) => Math.round(amount * 10_000) / 10_000
63
64// A project ledger read back from the store, whatever was kept there.
65export const saneProjects = (value: unknown): Projects =>
66  Object.fromEntries(
67    Object.entries(isRecord(value) ? value : {})
68      .filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
69      .map(([day, sums]) => [
70        day,
71        Object.fromEntries(Object.entries(sums).filter((entry): entry is [string, number] => isSum(entry[1]))),
72      ]),
73  )
74
75export const addProject = (projects: Projects, at: number, name: string, amount: number): Projects => {
76  const day = dayOf(at)
77  const oldest = dayOf(at - KEPT_DAYS * DAY_MS)
78  const sums = projects[day] ?? {}
79
80  return Object.fromEntries(
81    Object.entries({ ...projects, [day]: { ...sums, [name]: cents((sums[name] ?? 0) + amount) } }).filter(
82      ([kept]) => kept >= oldest,
83    ),
84  )
85}
86
87// The last seven days by project, most first.
88export const byProject = (projects: Projects, at: number) => {
89  const now = new Date(at)
90  const sums: Record<string, number> = {}
91
92  for (let index = 0; index < WEEK_DAYS; index += 1) {
93    const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - index)
94
95    for (const [name, amount] of Object.entries(projects[named(date)] ?? {})) {
96      sums[name] = cents((sums[name] ?? 0) + amount)
97    }
98  }
99
100  return Object.entries(sums)
101    .map(([name, sum]) => ({ name, total: sum }))
102    .sort((a, b) => b.total - a.total)
103    .slice(0, TOP)
104}
105
106// The heaviest turns read back from the store, whatever was kept there.
107export const saneTurns = (value: unknown): Costly[] =>
108  (Array.isArray(value) ? value : []).flatMap(entry => {
109    if (!isRecord(entry) || typeof entry.at !== 'number' || !Number.isFinite(entry.at)) {
110      return []
111    }
112
113    if (!isSum(entry.tokens) || typeof entry.project !== 'string') {
114      return []
115    }
116
117    const tools = isRecord(entry.tools) ? entry.tools : {}
118
119    return [
120      {
121        at: entry.at,
122        tokens: entry.tokens,
123        project: entry.project,
124        tools: Object.fromEntries(
125          Object.entries(tools).filter(
126            (pair): pair is [string, number] => Number.isInteger(pair[1]) && (pair[1] as number) > 0,
127          ),
128        ),
129      },
130    ]
131  })
132
133// The day's heaviest turns with one more: five at most, other days let go.
134export const keepTurn = (turns: readonly Costly[], turn: Costly, now: number) =>
135  [...turns.filter(({ at }) => dayOf(at) === dayOf(now)), turn]
136    .sort((a, b) => b.tokens - a.tokens)
137    .slice(0, TOP)
138
139// What a reading adds: its cost over the reading before. With no reading
140// before, this is where the person came in and nothing is added; a session
141// that had no cost yet began at nothing; a cost that fell was started over.
142export const spentSince = (before: { usd: number | null } | null, after: number | null) => {
143  if (before === null || after === null) {
144    return 0
145  }
146
147  const held = before.usd ?? 0
148
149  return after >= held ? after - held : after
150}
151
152export const add = (ledger: Ledger, at: number, amount: number): Ledger => {
153  const day = dayOf(at)
154  const oldest = dayOf(at - KEPT_DAYS * DAY_MS)
155  // To a hundredth of a cent: sums of fractions drift otherwise.
156  const total = Math.round(((ledger.days[day] ?? 0) + amount) * 10_000) / 10_000
157
158  return {
159    ...ledger,
160    days: Object.fromEntries(
161      Object.entries({ ...ledger.days, [day]: total }).filter(([kept]) => kept >= oldest),
162    ),
163  }
164}
165
166export const today = (ledger: Ledger, at: number) => ledger.days[dayOf(at)] ?? 0
167
168// The last seven days of a count by day, oldest first, today last.
169export const week = (days: Readonly<Record<string, number>>, at: number) => {
170  const now = new Date(at)
171
172  return Array.from({ length: WEEK_DAYS }, (_, index) => {
173    const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (WEEK_DAYS - 1 - index))
174
175    return { name: WEEKDAYS[date.getDay()] ?? '', value: days[named(date)] ?? 0 }
176  })
177}
178
179export const total = (days: readonly { value: number }[]) =>
180  days.reduce((sum, day) => sum + day.value, 0)
181
182// The week as a row of blocks, for a surface that draws no picture.
183export const spark = (days: readonly { value: number }[]) => {
184  const top = Math.max(...days.map(day => day.value), 0)
185
186  return days
187    .map(day => SPARKS[top === 0 ? 0 : Math.round((day.value / top) * (SPARKS.length - 1))])
188    .join('')
189}
190
191// A count of tokens as the pane says it: as it is under a thousand, then in
192// thousands (to a tenth below a hundred thousand), then in millions.
193export const shortTokens = (count: number) =>
194  count < 1000
195    ? String(Math.round(count))
196    : count < 100_000
197      ? `${(count / 1000).toFixed(1)}k`
198      : count < 1_000_000
199        ? `${Math.floor(count / 1000)}k`
200        : `${(count / 1_000_000).toFixed(1)}M`
201
202// A count by day read back from the store, whatever was kept there.
203export const saneDays = (value: unknown): Record<string, number> =>
204  Object.fromEntries(
205    Object.entries(isRecord(value) ? value : {}).filter((entry): entry is [string, number] => isSum(entry[1])),
206  )
207
208export const addDay = (days: Readonly<Record<string, number>>, at: number, amount: number) => {
209  const day = dayOf(at)
210  const oldest = dayOf(at - KEPT_DAYS * DAY_MS)
211
212  return Object.fromEntries(
213    Object.entries({ ...days, [day]: (days[day] ?? 0) + amount }).filter(([kept]) => kept >= oldest),
214  )
215}
216
217export const NO_SHARE: Share = { days: {}, tokens: {}, projects: {}, models: {}, spent: {} }
218export const NO_MODEL = '(unknown model)'
219
220// A share read back from its file, whatever was kept there. A share kept
221// before models were counted has none.
222export const saneShare = (value: unknown): Share => {
223  const { days, tokens, projects, models, spent, agents, runs } = isRecord(value) ? value : {}
224
225  return {
226    days: saneDays(days),
227    tokens: saneDays(tokens),
228    projects: saneProjects(projects),
229    models: saneProjects(models),
230    spent: saneProjects(spent),
231    ...(agents === undefined ? {} : { agents: saneProjects(agents) }),
232    ...(runs === undefined ? {} : { runs: saneProjects(runs) }),
233  }
234}
235
236// The days as CSV, a row a day, oldest first: what each cost and counted.
237export const exportCsv = (share: Share) => {
238  const days = [...new Set([...Object.keys(share.days), ...Object.keys(share.tokens)])].sort()
239
240  return [
241    'day,usd,tokens',
242    ...days.map(day => `${day},${(share.days[day] ?? 0).toFixed(4)},${share.tokens[day] ?? 0}`),
243    '',
244  ].join('\n')
245}
246
247// A model as the pane names it: no `claude-` before it, no date after it.
248export const modelName = (model: string) =>
249  model === '' ? NO_MODEL : model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
250
251// What a reading cost goes on the day in the session's own share, and on the
252// day under the project it was spent in.
253export const spend = (share: Share, at: number, amount: number, project?: string): Share => ({
254  ...share,
255  days: add({ ...EMPTY, days: share.days }, at, amount).days,
256  spent: project === undefined ? (share.spent ?? {}) : addProject(share.spent ?? {}, at, project, amount),
257})
258
259// A turn's tokens go on the day, and on the day under its project and the
260// model that answered; a subagent's under its agent type too, with a run
261// counted on its first turn.
262export const counted = (
263  share: Share,
264  at: number,
265  project: string,
266  tokens: number,
267  model = NO_MODEL,
268  agent?: { type: string; isNew: boolean },
269): Share => ({
270  ...share,
271  tokens: addDay(share.tokens, at, tokens),
272  projects: addProject(share.projects, at, project, tokens),
273  models: addProject(share.models, at, model, tokens),
274  ...(agent === undefined
275    ? {}
276    : {
277        agents: addProject(share.agents ?? {}, at, agent.type, tokens),
278        runs: agent.isNew ? addProject(share.runs ?? {}, at, agent.type, 1) : (share.runs ?? {}),
279      }),
280})
281
282// Every session's share summed: what the days have cost and counted, the
283// last thirty-one days of it.
284export const pool = (shares: readonly Share[], at: number): Share => {
285  const oldest = dayOf(at - KEPT_DAYS * DAY_MS)
286  const sum = (into: Record<string, number>, from: Readonly<Record<string, number>>, round: boolean) => {
287    for (const [key, value] of Object.entries(from)) {
288      const next = (into[key] ?? 0) + value
289
290      into[key] = round ? cents(next) : next
291    }
292  }
293  const days: Record<string, number> = {}
294  const tokens: Record<string, number> = {}
295  const projects: Projects = {}
296  const models: Projects = {}
297  const spent: Projects = {}
298  const agents: Projects = {}
299  const runs: Projects = {}
300  const byDay = (into: Projects, from: Projects = {}) => {
301    for (const [day, sums] of Object.entries(from)) {
302      into[day] = into[day] ?? {}
303      sum(into[day], sums, true)
304    }
305  }
306
307  for (const share of shares) {
308    sum(days, share.days, true)
309    sum(tokens, share.tokens, false)
310    byDay(projects, share.projects)
311    byDay(models, share.models)
312    byDay(spent, share.spent)
313    byDay(agents, share.agents)
314    byDay(runs, share.runs)
315  }
316
317  const recent = <T>(record: Record<string, T>) =>
318    Object.fromEntries(Object.entries(record).filter(([day]) => day >= oldest))
319
320  return {
321    days: recent(days),
322    tokens: recent(tokens),
323    projects: recent(projects),
324    models: recent(models),
325    spent: recent(spent),
326    ...(Object.keys(agents).length === 0 ? {} : { agents: recent(agents), runs: recent(runs) }),
327  }
328}
329
330// The last seven days of the subagents by agent type, the most tokens
331// first, five at most: each with its runs, and the share of every token
332// counted that they took.
333export const bySubagent = (share: Pick<Share, 'agents' | 'runs'>, all: number, at: number) => {
334  const tokens = byProject(share.agents ?? {}, at)
335  const runs = new Map(byProject(share.runs ?? {}, at).map(({ name, total }) => [name, total]))
336  const sum = tokens.reduce((held, { total }) => held + total, 0)
337
338  return {
339    rows: tokens.map(({ name, total }) => ({ name, total, runs: runs.get(name) ?? 0 })),
340    share: all <= 0 ? 0 : Math.round((sum / all) * 100),
341  }
342}
343
344// On a subscription no dollar is billed: a figure is the API's price of the
345// tokens, which a session tells by the 5-hour and weekly windows it reads.
346export const isPlanOf = (limits: readonly unknown[]) => limits.length > 0
347
348// What a dollar figure is called, and what a budget it has filled is.
349const worthOf = (isPlan: boolean) => (isPlan ? 'API worth' : 'cost')
350const usedUp = (isPlan: boolean) => (isPlan ? 'is used at API prices' : 'is spent')
351
352export const budgetShare = (ledger: Ledger, at: number, budget: number) =>
353  (today(ledger, at) / budget) * 100
354
355// What of the day's budget is news: each line once a day, for every session,
356// so what was said is noted in the ledger itself.
357export const budgetNews = (ledger: Ledger, at: number, budget: number | null, isPlan = false) => {
358  if (budget === null) {
359    return { notices: [], ledger }
360  }
361
362  const day = dayOf(at)
363  const spent = today(ledger, at)
364  const share = Math.round(budgetShare(ledger, at, budget))
365  const held = ledger.told.day === day ? ledger.told.levels : []
366  const passed = BUDGET_LEVELS.filter(level => share >= level)
367  const top = passed.filter(level => !held.includes(level)).at(-1)
368  const left = usd(Math.max(0, budget - spent))
369  const notices: Notice[] =
370    top === undefined
371      ? []
372      : top === 100
373        ? [
374            {
375              kind: 'spent',
376              title: `${BUDGET} of ${usd(budget)} ${usedUp(isPlan)}`,
377              text: `Today so far: ${usd(spent)}.`,
378              line: `${BUDGET} of ${usd(budget)} ${usedUp(isPlan)}: ${usd(spent)} so far`,
379              bar: { name: BUDGET, value: 1, label: `${share}%`, status: `${usd(spent)} so far` },
380            },
381          ]
382        : [
383            {
384              kind: 'budget',
385              title: `Today's ${worthOf(isPlan)} at ${share}% of ${usd(budget)}`,
386              text: `${left} left today.`,
387              line: `Today's ${worthOf(isPlan)} at ${share}% of ${usd(budget)}, ${left} left`,
388              bar: { name: BUDGET, value: share / 100, label: `${share}%`, status: `${left} left` },
389            },
390          ]
391
392  return {
393    notices,
394    ledger: {
395      ...ledger,
396      told: { day, levels: BUDGET_LEVELS.filter(level => held.includes(level) || passed.includes(level)) },
397    },
398  }
399}
400
401// The week a moment is in, named by its Monday: the local date.
402export const weekOf = (at: number) => {
403  const date = new Date(at)
404
405  return named(new Date(date.getFullYear(), date.getMonth(), date.getDate() - ((date.getDay() + 6) % 7)))
406}
407
408// What the week has cost so far: Monday to today.
409export const weekSpent = (days: Readonly<Record<string, number>>, at: number) => {
410  const monday = weekOf(at)
411  const now = dayOf(at)
412
413  return cents(Object.entries(days).reduce((sum, [day, amount]) => (day >= monday && day <= now ? sum + amount : sum), 0))
414}
415
416// The month a moment is in, as `2026-10`: the local date.
417export const monthOf = (at: number) => dayOf(at).slice(0, 7)
418
419// What the month has cost so far: its first day to today.
420export const monthSpent = (days: Readonly<Record<string, number>>, at: number) => {
421  const month = monthOf(at)
422  const now = dayOf(at)
423
424  return cents(Object.entries(days).reduce((sum, [day, amount]) => (day.startsWith(month) && day <= now ? sum + amount : sum), 0))
425}
426
427// The month so far in a line: its dollars, its days, a day's on average.
428export const monthLine = (days: Readonly<Record<string, number>>, at: number) => {
429  const spent = monthSpent(days, at)
430  const gone = new Date(at).getDate()
431
432  return { spent, line: `${gone} ${gone === 1 ? 'day' : 'days'} · ${usd(spent / gone)} a day` }
433}
434
435export type MonthTold = { month: string; levels: number[] }
436
437export const saneMonthTold = (value: unknown): MonthTold => {
438  const { month, levels } = isRecord(value) ? value : {}
439
440  return {
441    month: typeof month === 'string' ? month : '',
442    levels: Array.isArray(levels) ? BUDGET_LEVELS.filter(level => levels.includes(level)) : [],
443  }
444}
445
446// What of the month's budget is news: each line once a month, for every
447// session, as the week's is.
448export const monthNews = (days: Readonly<Record<string, number>>, at: number, budget: number | null, told: MonthTold, isPlan = false) => {
449  if (budget === null) {
450    return { notices: [], told }
451  }
452
453  const month = monthOf(at)
454  const said = spanNews(monthSpent(days, at), budget, told.month === month ? told.levels : [], 'month', isPlan)
455
456  return { notices: said.notices, told: { month, levels: said.levels } }
457}
458
459// A budget over a span of days, the week or the month: news at 80% and when
460// spent, each once a span.
461const spanNews = (spent: number, budget: number, held: readonly number[], span: 'week' | 'month', isPlan: boolean) => {
462  const share = Math.round((spent / budget) * 100)
463  const passed = BUDGET_LEVELS.filter(level => share >= level)
464  const top = passed.filter(level => !held.includes(level)).at(-1)
465  const left = usd(Math.max(0, budget - spent))
466  const name = `This ${span}'s budget`
467  const notices: Notice[] =
468    top === undefined
469      ? []
470      : top === 100
471        ? [
472            {
473              kind: 'spent',
474              title: `${name} of ${usd(budget)} ${usedUp(isPlan)}`,
475              text: `This ${span} so far: ${usd(spent)}.`,
476              line: `${name} of ${usd(budget)} ${usedUp(isPlan)}: ${usd(spent)} so far`,
477              bar: { name, value: 1, label: `${share}%`, status: `${usd(spent)} so far` },
478            },
479          ]
480        : [
481            {
482              kind: 'budget',
483              title: `This ${span}'s ${worthOf(isPlan)} at ${share}% of ${usd(budget)}`,
484              text: `${left} left this ${span}.`,
485              line: `This ${span}'s ${worthOf(isPlan)} at ${share}% of ${usd(budget)}, ${left} left`,
486              bar: { name, value: share / 100, label: `${share}%`, status: `${left} left` },
487            },
488          ]
489
490  return { notices, levels: BUDGET_LEVELS.filter(level => held.includes(level) || passed.includes(level)) }
491}
492
493export type WeekTold = { week: string; levels: number[] }
494
495export const saneWeekTold = (value: unknown): WeekTold => {
496  const { week, levels } = isRecord(value) ? value : {}
497
498  return {
499    week: typeof week === 'string' ? week : '',
500    levels: Array.isArray(levels) ? BUDGET_LEVELS.filter(level => levels.includes(level)) : [],
501  }
502}
503
504// What of the week's budget is news: each line once a week, for every
505// session, so what was said is kept beside the ledger.
506export const weekNews = (days: Readonly<Record<string, number>>, at: number, budget: number | null, told: WeekTold, isPlan = false) => {
507  if (budget === null) {
508    return { notices: [], told }
509  }
510
511  const week = weekOf(at)
512  const said = spanNews(weekSpent(days, at), budget, told.week === week ? told.levels : [], 'week', isPlan)
513
514  return { notices: said.notices, told: { week, levels: said.levels } }
515}
516
517export type ProjectTold = { day: string; levels: Record<string, number[]> }
518
519export const saneProjectTold = (value: unknown): ProjectTold => {
520  const { day, levels } = isRecord(value) ? value : {}
521
522  return {
523    day: typeof day === 'string' ? day : '',
524    levels: Object.fromEntries(
525      Object.entries(isRecord(levels) ? levels : {}).map(([name, held]) => [
526        name,
527        Array.isArray(held) ? BUDGET_LEVELS.filter(level => held.includes(level)) : [],
528      ]),
529    ),
530  }
531}
532
533// What of each project's budget is news: each line once a day per project,
534// for every session, so what was said is kept beside the ledger.
535export const projectNews = (
536  spent: Readonly<Projects>,
537  at: number,
538  budgets: Readonly<Record<string, number>>,
539  told: ProjectTold,
540  isPlan = false,
541) => {
542  const day = dayOf(at)
543  const held = told.day === day ? told.levels : {}
544  const notices: Notice[] = []
545  const levels: Record<string, number[]> = { ...held }
546
547  for (const [name, budget] of Object.entries(budgets)) {
548    const used = spent[day]?.[name] ?? 0
549    const share = Math.round((used / budget) * 100)
550    const before = held[name] ?? []
551    const passed = BUDGET_LEVELS.filter(level => share >= level)
552    const top = passed.filter(level => !before.includes(level)).at(-1)
553    const left = usd(Math.max(0, budget - used))
554    const label = `${name}'s budget`
555
556    if (top === 100) {
557      notices.push({
558        kind: 'spent',
559        title: `${label} of ${usd(budget)} ${usedUp(isPlan)}`,
560        text: `Today so far: ${usd(used)}.`,
561        line: `${label} of ${usd(budget)} ${usedUp(isPlan)}: ${usd(used)} so far`,
562        bar: { name: label, value: 1, label: `${share}%`, status: `${usd(used)} so far` },
563      })
564    } else if (top !== undefined) {
565      notices.push({
566        kind: 'budget',
567        title: `${isPlan ? `${name}'s API worth` : name} today at ${share}% of ${usd(budget)}`,
568        text: `${left} left today.`,
569        line: `${isPlan ? `${name}'s API worth` : name} today at ${share}% of ${usd(budget)}, ${left} left`,
570        bar: { name: label, value: share / 100, label: `${share}%`, status: `${left} left` },
571      })
572    }
573
574    if (passed.length > 0) {
575      levels[name] = BUDGET_LEVELS.filter(level => before.includes(level) || passed.includes(level))
576    }
577  }
578
579  return notices.length === 0 ? { notices, told } : { notices, told: { day, levels } }
580}
581
582// A turn's tokens, read field by field: one it cannot read counts nothing,
583// and `isWhole` says whether every one was read. A turn with none readable
584// has none.
585export const turnTokens = (usage: unknown) => {
586  const fields = isRecord(usage) ? usage : {}
587  const fieldOf = (name: string) => (isSum(fields[name]) ? (fields[name] as number) : null)
588  const read = [
589    fieldOf('input_tokens'),
590    fieldOf('output_tokens'),
591    fieldOf('cache_read_input_tokens'),
592    fieldOf('cache_creation_input_tokens'),
593  ]
594  const [input, output, cacheRead, cacheWrite] = read.map(value => value ?? 0)
595
596  return read.every(value => value === null)
597    ? null
598    : {
599        input: input ?? 0,
600        output: output ?? 0,
601        cacheRead: cacheRead ?? 0,
602        cacheWrite: cacheWrite ?? 0,
603        isWhole: read.every(value => value !== null),
604      }
605}
606
607// What a session that ends counted and spent, beside the average of the
608// other sessions that counted anything in the last seven days; nothing for
609// a session that counted nothing.
610export const sessionSummary = (own: Share, others: readonly Share[], at: number, isPlan = false) => {
611  const sumOf = (record: Readonly<Record<string, number>>, from = '') =>
612    Object.entries(record).reduce((sum, [day, value]) => (day >= from ? sum + value : sum), 0)
613  const tokens = sumOf(own.tokens)
614  const spent = cents(sumOf(own.days))
615
616  if (tokens === 0 && spent === 0) {
617    return null
618  }
619
620  const from = dayOf(at - (WEEK_DAYS - 1) * DAY_MS)
621  const weeks = others.map(share => sumOf(share.tokens, from)).filter(sum => sum > 0)
622  const average = weeks.length === 0 ? 0 : weeks.reduce((sum, value) => sum + value, 0) / weeks.length
623  const beside =
624    average === 0 || tokens === 0
625      ? ''
626      : `, ${(tokens / average).toFixed(1)}× your average session this week (${shortTokens(average)})`
627
628  return `This session: ${shortTokens(tokens)} tokens, ${usd(spent)}${isPlan ? ' at API prices' : ''}${beside}.`
629}
630
631// A turn far heavier than usual: past five times the median of the last
632// turns, never under a floor; with fewer than three turns to go by, a higher
633// floor stands alone.
634const HEAVY_TIMES = 5
635const HEAVY_FLOOR = 300_000
636const HEAVY_ALONE = 500_000
637const HEAVY_KNOWN = 3
638
639const median = (values: readonly number[]) => {
640  const sorted = [...values].sort((a, b) => a - b)
641  const middle = Math.floor(sorted.length / 2)
642
643  return sorted.length % 2 === 1 ? (sorted[middle] ?? 0) : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
644}
645
646export const heavyThreshold = (past: readonly number[]) =>
647  past.length < HEAVY_KNOWN ? HEAVY_ALONE : Math.max(HEAVY_FLOOR, HEAVY_TIMES * median(past))
648
649export const heavyLine = (running: number, past: readonly number[]) => {
650  const usual = past.length < HEAVY_KNOWN ? 0 : median(past)
651
652  return {
653    title: `This turn has used ${shortTokens(running)} tokens`,
654    text: usual === 0 ? 'Esc stops it.' : `${Math.round(running / usual)}× a usual turn (${shortTokens(usual)}). Esc stops it.`,
655  }
656}
657
658// Tips from what the counts already say, the most pressing first, two at
659// most: a full context, a turn that missed the cache, a day far above the
660// week. `days` is the week, oldest first, today last.
661const TIPS = 2
662const TIP_CONTEXT = 70
663const TIP_CACHE = 50
664const TIP_INPUT = 20_000
665const TIP_RATIO = 2
666
667export const tips = (seen: { turn: TurnTokens | null; percent: number | null; days: readonly { value: number }[] }) => {
668  const cache = cacheShare(seen.turn)
669  const input = seen.turn === null ? 0 : seen.turn.input + seen.turn.cacheRead + seen.turn.cacheWrite
670  const before = seen.days.slice(0, -1)
671  const average = before.length === 0 ? 0 : total(before) / before.length
672  const now = seen.days.at(-1)?.value ?? 0
673  const ratio = average === 0 ? 0 : now / average
674
675  return [
676    seen.percent !== null && seen.percent >= TIP_CONTEXT
677      ? `Context at ${Math.round(seen.percent)}%: /compact before a big task, so the turns after it read less.`
678      : null,
679    cache !== null && cache < TIP_CACHE && input >= TIP_INPUT
680      ? `Only ${cache}% of the last turn's input came from the cache: a long pause, or a change early in the conversation, makes a turn pay for its whole input again.`
681      : null,
682    ratio >= TIP_RATIO
683      ? `Today is ${ratio.toFixed(1)}× your daily average of the last six days (${shortTokens(average)}).`
684      : null,
685  ]
686    .filter(line => line !== null)
687    .slice(0, TIPS)
688}
689
690// What of a turn's input was read from the cache, in percent: the higher,
691// the less the turn cost.
692// The colour a share read from the cache is said in: calm from half, a
693// warning from a fifth, an error under it.
694export const shareTone = (share: number) => (share >= 50 ? 'success' : share >= 20 ? 'warning' : 'error') as Tone
695
696export const cacheShare = (turn: TurnTokens | null) => {
697  const input = turn === null ? 0 : turn.input + turn.cacheRead + turn.cacheWrite
698
699  return turn === null || input === 0 ? null : Math.round((turn.cacheRead / input) * 100)
700}
701
hooks/cache.ts 243 lines
1import type { Notice } from '../types'
2import { shortTokens } from './cost'
3import type { Choice } from './advice'
4
5// How long the prompt cache lasts, learned from the pauses themselves: after
6// each, the next turn's first response says how much of its input it read
7// from the cache. Most of it, and the cache outlasted the pause; little of
8// it, and the pause outlasted the cache. The cache lasts past every pause
9// that kept it and short of the shortest that lapsed it past those.
10//
11// That range is wide when the pauses are few or far apart (kept through 24m,
12// lapsed after 4h31m), and the cache lasts one of two times: 5 minutes, or
13// an hour. So the learned time is the one of those the range holds, the
14// shorter where it holds both: a warning early beats one too late. Where it
15// holds neither, the range's own end stands; with no lapse seen yet, only a
16// range that rules out the one leaves the other.
17
18export type Pause = { gap: number; isKept: boolean; at: number }
19type Read = { input: number; cacheRead: number; cacheWrite: number }
20
21const KEPT_SHARE = 0.5
22const LAPSED_SHARE = 0.2
23// A context this small says little; one that shrank this far was compacted
24// or cleared, and its prefix is new whatever the time.
25const MIN_CONTEXT = 20_000
26const SHRUNK = 0.5
27const KEPT_PAUSES = 50
28// Just after a turn the band says nothing of the cache; near its end, for two
29// minutes or the last 40% of a short one, it warns.
30const QUIET_MS = 15_000
31const NEAR_MS = 2 * 60_000
32const NEAR_SHARE = 0.4
33const MINUTE_MS = 60_000
34const HOUR_MS = 60 * MINUTE_MS
35const TTLS = [5 * MINUTE_MS, HOUR_MS]
36
37export const judgePause = (gap: number, before: number | null, read: Read, at: number): Pause | null => {
38  const input = read.input + read.cacheRead + read.cacheWrite
39
40  if (before === null || before < MIN_CONTEXT || input < before * SHRUNK) {
41    return null
42  }
43
44  const share = read.cacheRead / Math.max(1, input)
45
46  return share >= KEPT_SHARE ? { gap, isKept: true, at } : share < LAPSED_SHARE ? { gap, isKept: false, at } : null
47}
48
49export const keepPauses = (pauses: readonly Pause[], pause: Pause) => [...pauses, pause].slice(-KEPT_PAUSES)
50
51// The pauses read back from the store, whatever was kept there.
52export const sanePauses = (value: unknown): Pause[] =>
53  (Array.isArray(value) ? value : []).flatMap(entry => {
54    const { gap, isKept, at } = (typeof entry === 'object' && entry !== null ? entry : {}) as Record<string, unknown>
55
56    return typeof gap === 'number' && Number.isFinite(gap) && gap >= 0 && typeof isKept === 'boolean' && typeof at === 'number'
57      ? [{ gap, isKept, at }]
58      : []
59  })
60
61export const learnedTtl = (pauses: readonly Pause[]) => {
62  const kept = Math.max(0, ...pauses.filter(({ isKept }) => isKept).map(({ gap }) => gap))
63  // A lapse under a pause that kept the cache was something else: a change
64  // early in the conversation, not the time.
65  const lapses = pauses.filter(({ isKept, gap }) => !isKept && gap > kept).map(({ gap }) => gap)
66  const lapsed = lapses.length === 0 ? null : Math.min(...lapses)
67  const held = TTLS.filter(ttl => ttl >= kept && (lapsed === null || ttl <= lapsed))
68  const ttl = lapsed === null ? (held.length === 1 && kept > 0 ? (held[0] ?? null) : null) : (held[0] ?? lapsed)
69
70  return { ttl, kept, pauses: pauses.length }
71}
72
73// A time said short: minutes under the hour, hours and minutes past it.
74const span = (ms: number) => {
75  const minutes = Math.round(ms / MINUTE_MS)
76
77  return minutes < 60 ? `${minutes}m` : minutes % 60 === 0 ? `${minutes / 60}h` : `${Math.floor(minutes / 60)}h${minutes % 60}m`
78}
79
80export const cachePhase = (idle: number, ttl: number | null) => {
81  if (ttl === null || idle < QUIET_MS) {
82    return null
83  }
84
85  const left = Math.max(0, ttl - idle)
86
87  if (left === 0) {
88    return { phase: 'gone' as const, left: 0 }
89  }
90
91  return { phase: left <= Math.min(NEAR_MS, ttl * NEAR_SHARE) ? ('near' as const) : ('calm' as const), left }
92}
93
94// Minutes while there is time; seconds in the last two minutes.
95const clock = (ms: number) => {
96  const seconds = Math.ceil(ms / 1000)
97
98  if (ms >= NEAR_MS) {
99    return `${Math.floor(ms / MINUTE_MS)}m`
100  }
101
102  return seconds >= 60 ? `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`
103}
104
105export const cacheWord = (said: { phase: 'calm' | 'near' | 'gone'; left: number }, context: number | null) =>
106  said.phase === 'gone'
107    ? `cache lapsed${context === null ? '' : ` · next turn re-reads ${shortTokens(context)}`}`
108    : said.phase === 'near'
109      ? `cache lapses in ${clock(said.left)}`
110      : `cache ${clock(said.left)}`
111
112// A context this large is worth compacting before a break: the compaction
113// reads it from the cache while it lasts, and the next turn re-writes only
114// the summary. A smaller one costs less to keep warm with a short turn.
115export const COMPACT_WORTH = 60_000
116
117// What the API charges for the cache, as shares of the model's input price:
118// a read at the model's own rate, a write by how long the entry lasts, a
119// quarter more for five minutes and twice the price for an hour. Where the
120// time is not learned yet, the hour: a subscription's own, and the dearer.
121// A read is a tenth, but a twentieth on Opus 5.5 and Sonnet 5.5 and a
122// fortieth on Fable 5.1 and Mythos 5.1 (the models overview, 2026-10-18); a
123// model not named here, or none seen yet, reads at the tenth.
124const READS: readonly (readonly [string, number])[] = [
125  ['fable-5-1', 0.025],
126  ['mythos-5-1', 0.025],
127  ['opus-5-5', 0.05],
128  ['sonnet-5-5', 0.05],
129]
130const READ_SHARE = 0.1
131const WRITE_SHORT = 1.25
132const WRITE_HOUR = 2
133const SHARES: Readonly<Record<number, string>> = { 10: 'a tenth', 20: 'a twentieth', 40: 'a fortieth' }
134
135export type Rates = { read: number; write: number }
136
137export const cacheRates = (model: string | null, ttl: number | null): Rates => {
138  const id = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
139
140  return {
141    read: READS.find(([family]) => id.includes(family))?.[1] ?? READ_SHARE,
142    write: ttl === TTLS[0] ? WRITE_SHORT : WRITE_HOUR,
143  }
144}
145
146// A read's share of the price in words: a tenth, a twentieth, a fortieth.
147export const shareOf = (rate: number) => {
148  const parts = Math.round(1 / rate)
149
150  return SHARES[parts] ?? `1/${parts}`
151}
152
153type Said = {
154  rates?: Rates
155  // What the engine reckons re-writing the cache costs in dollars, where it
156  // said: on resuming a session.
157  usd?: number | null
158  // Keep warm sends a turn that shows, where its quiet ping did not hold the
159  // cache.
160  isWarmShown?: boolean
161}
162
163// What each way out does and costs, said while the pointer rests on it. The
164// figures are the API's price in tokens at the full input price.
165export const cacheHints = (context: number | null, { rates = cacheRates(null, null), usd = null, isWarmShown = false }: Said = {}) => {
166  const n = context === null ? 'the context' : shortTokens(context)
167  const dollars = usd === null ? '' : `, $${usd.toFixed(2)} at API price`
168  const at = (price: number, isPriced = false) =>
169    context === null ? '' : ` (about ${shortTokens(context * price)} at full price${isPriced ? dollars : ''})`
170
171  return {
172    compact: `Compacts while ${n} is still cached, so it is read at ${shareOf(rates.read)} of the price. The next turn starts from a short summary. Cost: earlier turns live on only in the summary.`,
173    warm: isWarmShown
174      ? `Sends a one-line turn now: Claude reads ${n} from the cache${at(rates.read)} and the cache's time starts over. It shows in the conversation: a quiet ping did not hold this cache. For a long break, Compact now costs less.`
175      : `Reads ${n} from the cache once${at(rates.read)}, without a turn: nothing shows in the conversation, and the cache's time starts over. For a long break, Compact now costs less.`,
176    lapse: `Nothing is sent. Your next turn writes ${n} to the cache again${at(rates.write, true)}.`,
177    why: `Every step of a turn reads all ${n} again${at(rates.read)}. Once the cache lapses, the next turn writes it all again${at(rates.write, true)}, ${Math.floor(rates.write / rates.read)} times a cached step. A smaller context makes every step and every lapse cheaper.`,
178    fresh: 'A new conversation (/clear): nothing is re-read, but Claude forgets this one. For something new.',
179    compactLapsed: `Reads ${n} once more to summarise it${at(1)}. The turns after it read the summary, not ${n}. Worth it if you'll carry on for a while.`,
180    gone: `Your next turn writes all ${n} again${at(rates.write, true)}. Start fresh re-reads nothing; Compact pays once so the turns after it read less.`,
181  }
182}
183
184// What the crab says once a pause, two minutes before the cache lapses, and
185// what it offers: compact while the cache holds, keep it warm, or let it go.
186export const cacheNotice = (left: number, ttl: number, context: number | null, said: Said = {}): Notice => {
187  const kept = context === null ? 'the context' : shortTokens(context)
188  const isLarge = context !== null && context >= COMPACT_WORTH
189  const hints = cacheHints(context, said)
190  const compactNow: Choice = { key: 'compact', label: 'Compact now', hint: hints.compact }
191  const warm: Choice = { key: 'warm', label: 'Keep warm', hint: hints.warm }
192  const lapse: Choice = { key: 'lapse', label: 'Let it lapse', hint: hints.lapse }
193
194  return {
195    kind: 'cache',
196    title: `Cache lapses in ${clock(left)}`,
197    text: isLarge
198      ? `Leaving? Compact now while it's cached: the next turn re-reads a summary, not ${kept}.`
199      : said.isWarmShown === true
200        ? `Keep warm sends a short turn now, so ${kept} stays cached.`
201        : `Keep warm reads the cache once, so ${kept} stays cached.`,
202    line: `Cache lapses in ${clock(left)}: reply now to keep ${kept} cached`,
203    bar: { name: 'Prompt cache', value: Math.min(1, Math.max(0, 1 - left / ttl)), label: clock(left), status: 'left' },
204    choices: isLarge ? [compactNow, warm, lapse] : [warm, lapse, compactNow],
205  }
206}
207
208// A ping that read from the cache kept it: what it read starts its time over.
209// What it wrote besides is not a lapse: the first fork after a turn writes a
210// block of its own (seen live: 32k beside a 77k context, 30 seconds on), and
211// the next one reads that too.
212export const isWarmPing = (read: number) => read > 0
213
214// The ping wrote enough besides its read to be worth saying: a tenth of the
215// read or more.
216export const isHeavyPing = (read: number, written: number) => written >= 0.1 * read
217
218// What Keep warm sends where its quiet ping fell short: a turn as short as
219// one can be, read from the cache.
220export const WARM_TEXT = 'Keeping the prompt cache warm: reply with only "ok".'
221
222// While the cache has lapsed, a context this large gets the band's ways out:
223// start fresh, or compact before the turns that would re-read it.
224export const LAPSED_WORTH = 50_000
225
226export const hasLapsedActions = (said: { phase: 'calm' | 'near' | 'gone' } | null, context: number | null) =>
227  said?.phase === 'gone' && context !== null && context >= LAPSED_WORTH
228
229// The cache is warned of this long before it lapses, where it lasts longer.
230export const CACHE_WARN_MS = NEAR_MS
231
232export const cacheLine = (learned: { ttl: number | null; kept: number; pauses: number }) => {
233  if (learned.pauses === 0) {
234    return null
235  }
236
237  const plural = `${learned.pauses} ${learned.pauses === 1 ? 'pause' : 'pauses'}`
238
239  return learned.ttl !== null
240    ? `cache lapses after ~${span(learned.ttl)} idle · learned from ${plural}`
241    : `cache kept through ${span(learned.kept)} idle so far · ${plural}`
242}
243
hooks/advice.ts 174 lines
1// What a heavy turn's notice says and offers: where its tokens went, why it
2// is heavy, and the choices that fit, the one that fits best first.
3
4import { cacheRates, shareOf } from './cache'
5
6export type ChoiceKey = 'stop' | 'keep' | 'agents' | 'compactAfter' | 'raise' | 'compact' | 'fresh' | 'continue' | 'warm' | 'lapse'
7
8// A hint says what the choice does and costs, while the pointer rests on it.
9export type Choice = { key: ChoiceKey; label: string; hint?: string }
10
11// A turn's tokens by where they went: the context read again from the cache
12// at each step, what was read anew, what was written, and its subagents'.
13export type TurnParts = { reread: number; anew: number; output: number; agents: number }
14
15export const NO_PARTS: TurnParts = { reread: 0, anew: 0, output: 0, agents: 0 }
16
17type Step = { input: number; output: number; cacheRead: number; cacheWrite: number }
18
19export const addStep = (parts: TurnParts, used: Step, isSubagent: boolean): TurnParts =>
20  isSubagent
21    ? { ...parts, agents: parts.agents + used.input + used.output + used.cacheRead + used.cacheWrite }
22    : {
23        ...parts,
24        reread: parts.reread + used.cacheRead,
25        anew: parts.anew + used.input + used.cacheWrite,
26        output: parts.output + used.output,
27      }
28
29const PARTS: readonly { key: keyof TurnParts; name: string; color: string }[] = [
30  { key: 'reread', name: 'context re-read', color: '#a59be0' },
31  { key: 'anew', name: 'read anew', color: '#ff6b80' },
32  { key: 'output', name: 'output', color: '#e3a008' },
33  { key: 'agents', name: 'subagents', color: '#7cc4ff' },
34]
35
36const totalOf = (parts: TurnParts) => parts.reread + parts.anew + parts.output + parts.agents
37
38// The split as the notice draws it: each part with a share, the largest first.
39export const splitOf = (parts: TurnParts) => {
40  const total = totalOf(parts)
41
42  return total <= 0
43    ? []
44    : PARTS.map(({ key, name, color }) => ({ name, color, share: parts[key] / total }))
45        .filter(({ share }) => share > 0)
46        .sort((a, b) => b.share - a.share)
47}
48
49const LIMIT_AT = 85
50const AGENTS_SHARE = 0.5
51const CONTEXT_AT = 70
52const ANEW_SHARE = 0.5
53const percent = (share: number) => `${Math.round(share * 100)}%`
54
55const STOP: Choice = { key: 'stop', label: 'Stop now' }
56const KEEP: Choice = { key: 'keep', label: 'Keep going' }
57
58// Why the turn is heavy and what to do, from the gravest reason down: a
59// 5-hour window near its limit, subagents doing most of it, a context near
60// full, a cache that had lapsed, or else what most of it was. A cache read's
61// share of the price is the model's (cacheRates), a tenth where none is said.
62export const heavyAdvice = (
63  parts: TurnParts,
64  seen: { five: number | null; context: number | null; agentTypes: readonly string[]; read?: number },
65): { reason: string; tone: 'error' | 'warning' | null; choices: Choice[] } => {
66  const total = Math.max(1, totalOf(parts))
67  const share = (key: keyof TurnParts) => parts[key] / total
68
69  if (seen.five !== null && seen.five >= LIMIT_AT) {
70    return {
71      reason: `5-hour window is at ${Math.round(seen.five)}%: this turn alone could reach the limit.`,
72      tone: 'error',
73      choices: [STOP, KEEP],
74    }
75  }
76
77  if (share('agents') >= AGENTS_SHARE) {
78    const kinds = [...new Set(seen.agentTypes)]
79    const named = kinds.length === 0 ? '' : `: ${kinds.slice(0, 3).join(', ')}`
80
81    return {
82      reason: `Subagents took ${percent(share('agents'))} of it${named}.`,
83      tone: 'warning',
84      choices: [KEEP, { key: 'agents', label: 'See agents' }, STOP],
85    }
86  }
87
88  if (seen.context !== null && seen.context >= CONTEXT_AT) {
89    return {
90      reason: `The context is at ${Math.round(seen.context)}%: each step reads all of it again.`,
91      tone: 'warning',
92      choices: [{ key: 'compactAfter', label: 'Compact after' }, STOP, KEEP],
93    }
94  }
95
96  // The lapse was paid at the turn's first step: a stop gets none of it back,
97  // so the way out is a smaller context for the next lapse to re-read.
98  if (share('anew') >= ANEW_SHARE) {
99    return {
100      reason: `The cache had lapsed: ${percent(share('anew'))} of it was read anew, and that is paid already: stopping won't get it back. Compact after, and the next lapse re-reads less.`,
101      tone: null,
102      choices: [
103        { ...KEEP, hint: `The re-read is paid already. From here on the steps read from the cache again, at ${shareOf(seen.read ?? cacheRates(null, null).read)} of the price.` },
104        {
105          key: 'compactAfter',
106          label: 'Compact after',
107          hint: 'This turn finishes, then the context is compacted. The next lapse re-reads a summary, not the whole conversation. Earlier turns live on only in the summary.',
108        },
109        { key: 'raise', label: 'Warn at 2×', hint: 'A turn must be twice as heavy before this notice comes again, for this session. Nothing else changes.' },
110      ],
111    }
112  }
113
114  const [most] = splitOf(parts)
115
116  return {
117    reason: most === undefined ? 'Many steps, each reading the context again.' : `Most of it is ${most.name}: ${percent(most.share)}.`,
118    tone: null,
119    choices: [KEEP, STOP, { key: 'raise', label: 'Warn at 2×' }],
120  }
121}
122
123const STOPPED_AT = 50
124
125// After a stop, what to do next: with the context half full or more, compact
126// it; with less, start fresh; or go on where it stopped.
127export const stoppedAdvice = (context: number | null, tokens: string) => {
128  const compact: Choice = { key: 'compact', label: 'Compact' }
129  const fresh: Choice = { key: 'fresh', label: 'Start fresh' }
130  const goOn: Choice = { key: 'continue', label: 'Continue' }
131  const isFull = context !== null && context >= STOPPED_AT
132
133  return {
134    title: tokens === '' ? 'Stopped. What next?' : `Stopped after ${tokens} tokens. What next?`,
135    text: isFull
136      ? `The context is at ${Math.round(context ?? 0)}%: Compact keeps a summary and frees it; Start fresh clears it.`
137      : 'Start fresh clears the conversation for a new start; Continue goes on where it stopped.',
138    choices: isFull ? [compact, fresh, goOn] : [fresh, goOn, compact],
139  }
140}
141
142type Part = { name: string; share: number; color: string }
143
144// The split as a bar of colours, for the desktop.
145export const splitSvg = (split: readonly Part[], width: number) => {
146  let at = 0
147  const runs = split.map(({ share, color }) => {
148    const wide = share * width
149    const run = `<rect x="${at.toFixed(1)}" width="${wide.toFixed(1)}" height="6" fill="${color}"/>`
150
151    at += wide
152
153    return run
154  })
155
156  return {
157    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="6" viewBox="0 0 ${width} 6"><rect width="${width}" height="6" rx="3" fill="#8b919c" fill-opacity=".25"/>${runs.join('')}</svg>`,
158    alt: split.map(({ name, share }) => `${name} ${Math.round(share * 100)}%`).join(', '),
159  }
160}
161
162// The split as runs of cells, for a terminal: each part as many cells as its
163// share, the cells always adding up.
164export const cellsOfSplit = (split: readonly Part[], cells: number) => {
165  const counts = split.map(({ share }) => Math.floor(share * cells))
166  const order = split.map(({ share }, index) => ({ index, rest: share * cells - Math.floor(share * cells) })).sort((a, b) => b.rest - a.rest)
167
168  for (const { index } of order.slice(0, Math.max(0, cells - counts.reduce((sum, count) => sum + count, 0)))) {
169    counts[index] = (counts[index] ?? 0) + 1
170  }
171
172  return split.map(({ color }, index) => ({ text: '█'.repeat(counts[index] ?? 0), color })).filter(({ text }) => text !== '')
173}
174
hooks/style.ts 518 lines
1import type {
2  LabelSet,
3  Layout,
4  Notify,
5  Palette,
6  PaletteName,
7  Part,
8  Quiet,
9  Style,
10  Tone,
11} from '../types'
12import { DEFAULTS, isHex, paletteOf } from './svg'
13import { brief, short, title } from './usage'
14
15// How the band looks, and the words of `/tokens` that change it.
16
17export const LAYOUTS: readonly Layout[] = ['rings', 'bars', 'compact', 'frames', 'stack']
18// Every palette holds a contrast of 3 to 1 on a light page as on a dark one.
19export const PALETTES: Record<PaletteName, Palette> = {
20  classic: DEFAULTS,
21  ocean: { success: '#1d8fc4', warning: '#9a62e6', error: '#e03c6e' },
22  sunset: { success: '#c17d0e', warning: '#e5583f', error: '#d0447f' },
23  // Two greys and a red: only a window at its limit takes a colour.
24  mono: { success: '#727b88', warning: '#858b95', error: '#e04f63' },
25  forest: { success: '#2f9e5f', warning: '#b8860b', error: '#d9482b' },
26  berry: { success: '#8b6fe8', warning: '#d457b0', error: '#e0385a' },
27  ice: { success: '#1b9bc2', warning: '#5b84e6', error: '#e0524f' },
28  ember: { success: '#bd8210', warning: '#dd6b20', error: '#d63a3a' },
29  mint: { success: '#139c93', warning: '#b98309', error: '#e05574' },
30  // Blue, amber and vermilion: told apart with the common colour blindnesses.
31  safe: { success: '#3d8fd1', warning: '#b98309', error: '#d55e00' },
32  // Bluish green, reddish purple and vermilion, after Okabe and Ito: told
33  // apart with every common colour blindness, blue and yellow's too.
34  okabe: { success: '#0f9270', warning: '#c35d97', error: '#cf5f00' },
35}
36export const PALETTE_NAMES = Object.keys(PALETTES) as PaletteName[]
37// The palettes by kind, for the picker: each is in one group.
38export const PALETTE_GROUPS: readonly { heading: string; names: readonly PaletteName[] }[] = [
39  { heading: 'Colour', names: ['classic', 'ocean', 'sunset', 'forest', 'berry', 'ice', 'ember', 'mint'] },
40  { heading: 'Grey', names: ['mono'] },
41  { heading: 'Colour-blind safe', names: ['safe', 'okabe'] },
42]
43export const LABEL_SETS: readonly LabelSet[] = ['auto', 'short', 'long', 'caps', 'thai']
44export const PARTS: readonly Part[] = ['context', 'countdown', 'forecast', 'cost', 'alerts', 'crab', 'breakdown']
45// Not part of a look: a preset neither sets these nor is unmade by them.
46const APART: readonly Part[] = ['alerts', 'crab', 'breakdown']
47export const NOTIFY: readonly Notify[] = ['both', 'app', 'windows']
48// A preset is a look: where alerts go is not part of one.
49export const PRESETS: Record<string, Pick<Style, 'layout' | 'palette' | 'labels' | 'hidden'>> = {
50  classic: { layout: 'rings', palette: 'classic', labels: 'auto', hidden: [] },
51  timeline: { layout: 'bars', palette: 'classic', labels: 'short', hidden: [] },
52  quiet: { layout: 'compact', palette: 'mono', labels: 'short', hidden: ['cost'] },
53  neon: { layout: 'rings', palette: 'ocean', labels: 'long', hidden: [] },
54  split: { layout: 'stack', palette: 'sunset', labels: 'long', hidden: [] },
55}
56
57const LEVELS: Record<string, Tone> = { ok: 'success', warn: 'warning', full: 'error' }
58const TONES: readonly Tone[] = ['success', 'warning', 'error']
59const THAI = [
60  { prefix: 'five_hour', name: '5 ชม.' },
61  { prefix: 'seven_day', name: 'สัปดาห์' },
62]
63// The notification outside the app, set under `alerts`: `alerts popup off`.
64const OUTSIDE = ['popup', 'privacy', 'quiet'] as const
65// The words of /tokens: the few used every day first, then the settings by
66// what they do, a line each, then the checks.
67const HELP = [
68  '/tokens words:',
69  '  main: (nothing) for the usage pane, style for its buttons, agents, context, list, help',
70  'settings, most also a button in /tokens style:',
71  `  look: a preset (${Object.keys(PRESETS).join(', ')}), layout <${LAYOUTS.join('|')}>, palette <${PALETTE_NAMES.join('|')}>, color <${Object.keys(LEVELS).join('|')}> <#hex>, color background <#hex|off>, labels <${LABEL_SETS.join('|')}>`,
72  `  parts: show|hide <${PARTS.join('|')}>`,
73  `  alerts: alerts <on|off|${NOTIFY.join('|')}>, alerts 60 <on|off>, alerts popup <on|off>, alerts privacy <on|off>, alerts quiet <from-to|off>`,
74  '  budgets: budget <dollars a day|off>, budget week <dollars|off>, budget month <dollars|off>, budget project <name|here> <dollars|off>',
75  '  data: export, tidy [now], reset',
76  'checks:',
77  '  alerts test [week|cache|lapsed]',
78].join('\n')
79const POINTER = '/tokens help lists the words'
80
81const among = <T extends string>(names: readonly T[], word: unknown): word is T =>
82  typeof word === 'string' && names.some(name => name === word)
83
84export const defaults = (options: Readonly<Record<string, unknown>>): Style => {
85  const chosen = paletteOf(options)
86
87  return {
88    layout: among(LAYOUTS, options.style) ? options.style : 'rings',
89    palette: 'classic',
90    // Only what the options change: an untouched level follows the palette.
91    colors: Object.fromEntries(
92      TONES.filter(level => chosen[level] !== DEFAULTS[level]).map(level => [
93        level,
94        chosen[level],
95      ]),
96    ),
97    labels: 'auto',
98    hidden: [],
99    notify: 'both',
100    hasEarlyAlert: false,
101    budget: null,
102    weekBudget: null,
103    monthBudget: null,
104    projectBudgets: {},
105    isPrivate: true,
106    quiet: null,
107    hasPopup: true,
108    background: null,
109  }
110}
111
112const isHour = (value: unknown): value is number =>
113  typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 23
114
115const quietOf = (value: unknown): Quiet | null => {
116  if (typeof value !== 'object' || value === null) {
117    return null
118  }
119
120  const { from, to } = value as Record<string, unknown>
121
122  return isHour(from) && isHour(to) && from !== to ? { from, to } : null
123}
124
125const hour = (value: number) => `${String(value).padStart(2, '0')}:00`
126
127// How far white text stands out from a hex colour, as WCAG reckons it; below
128// 4.5:1 it reads poorly.
129const READABLE = 4.5
130
131const contrastWithWhite = (hex: string) => {
132  const digits = hex.slice(1)
133  const full = digits.length === 3 ? [...digits].map(digit => digit + digit).join('') : digits
134  const channel = (at: number) => {
135    const value = parseInt(full.slice(at, at + 2), 16) / 255
136
137    return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
138  }
139  const luminance = 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
140
141  return 1.05 / (luminance + 0.05)
142}
143
144const isBudget = (value: unknown): value is number =>
145  typeof value === 'number' && Number.isFinite(value) && value > 0
146
147// What the store hands back is anything at all: a style, or nothing.
148export const sane = (value: unknown): Style | null => {
149  if (typeof value !== 'object' || value === null) {
150    return null
151  }
152
153  const { layout, palette, colors, labels, hidden, notify, hasEarlyAlert, budget, weekBudget, monthBudget, projectBudgets, isPrivate, quiet, hasPopup, background } =
154    value as Record<string, unknown>
155
156  if (!among(LAYOUTS, layout) || !among(PALETTE_NAMES, palette) || !among(LABEL_SETS, labels)) {
157    return null
158  }
159
160  const held = (typeof colors === 'object' && colors !== null ? colors : {}) as Record<
161    string,
162    unknown
163  >
164
165  return {
166    layout,
167    palette,
168    colors: Object.fromEntries(
169      TONES.filter(level => isHex(held[level])).map(level => [level, held[level]]),
170    ),
171    labels,
172    hidden: Array.isArray(hidden) ? PARTS.filter(part => hidden.includes(part)) : [],
173    // A style kept before alerts had a place has neither: the defaults stand.
174    notify: among(NOTIFY, notify) ? notify : 'both',
175    hasEarlyAlert: hasEarlyAlert === true,
176    // A style kept before budgets had a place has none.
177    budget: isBudget(budget) ? budget : null,
178    weekBudget: isBudget(weekBudget) ? weekBudget : null,
179    monthBudget: isBudget(monthBudget) ? monthBudget : null,
180    projectBudgets: Object.fromEntries(
181      Object.entries(typeof projectBudgets === 'object' && projectBudgets !== null ? projectBudgets : {}).filter(
182        (entry): entry is [string, number] => isBudget(entry[1]),
183      ),
184    ),
185    // A style kept before privacy and quiet hours had a place has the defaults.
186    isPrivate: isPrivate !== false,
187    quiet: quietOf(quiet),
188    // A style kept before the popup had a place has it on.
189    hasPopup: hasPopup !== false,
190    // A style kept before backgrounds had a place has none.
191    background: isHex(background) ? background : null,
192  }
193}
194
195export const paletteFor = (style: Style): Palette => ({
196  ...PALETTES[style.palette],
197  ...style.colors,
198})
199
200export const presetOf = (style: Style) =>
201  Object.keys(PRESETS).find(name => {
202    const preset = PRESETS[name]
203
204    return (
205      preset !== undefined &&
206      preset.layout === style.layout &&
207      preset.palette === style.palette &&
208      preset.labels === style.labels &&
209      preset.hidden.join() === style.hidden.filter(part => !APART.includes(part)).join() &&
210      Object.keys(style.colors).length === 0
211    )
212  })
213
214// The words a drawing uses: a window's name by the label set, and the three
215// words beside the figures. `auto` is the short set where a layout is tight.
216export const words = (style: Style) => {
217  const set =
218    style.labels !== 'auto'
219      ? style.labels
220      : style.layout === 'bars' || style.layout === 'compact'
221        ? 'short'
222        : 'long'
223  const name = (kind: string) => {
224    if (set === 'thai') {
225      return THAI.find(({ prefix }) => kind.startsWith(prefix))?.name ?? kind
226    }
227
228    if (set === 'caps') {
229      const known = short(kind)
230
231      return known === kind ? kind : known.toUpperCase()
232    }
233
234    return set === 'short' ? brief(kind) : title(kind)
235  }
236
237  return {
238    name,
239    context: set === 'caps' ? 'CTX' : 'context',
240    used: set === 'thai' ? 'ใช้ไป' : 'used',
241    left: set === 'thai' ? 'เหลือ' : 'left',
242  }
243}
244
245const describe = (style: Style) => {
246  const preset = presetOf(style)
247  const custom = Object.keys(style.colors).length > 0 ? ' with custom colours' : ''
248
249  return [
250    preset === undefined ? null : `preset ${preset}`,
251    `layout ${style.layout}`,
252    `palette ${style.palette}${custom}`,
253    `labels ${style.labels}`,
254    style.hidden.length === 0 ? 'all parts shown' : `hidden ${style.hidden.join(', ')}`,
255    style.hidden.includes('alerts')
256      ? null
257      : `alerts to ${style.notify}${style.hasEarlyAlert ? ' from 60%' : ''}`,
258    style.budget === null ? null : `budget $${style.budget.toFixed(2)} a day`,
259    style.weekBudget === null ? null : `budget $${style.weekBudget.toFixed(2)} a week`,
260    style.monthBudget === null ? null : `budget $${style.monthBudget.toFixed(2)} a month`,
261    ...Object.entries(style.projectBudgets).map(([name, amount]) => `budget $${amount.toFixed(2)} a day for ${name}`),
262    style.isPrivate ? null : 'privacy off',
263    style.quiet === null ? null : `quiet ${hour(style.quiet.from)} to ${hour(style.quiet.to)}`,
264    style.hasPopup ? null : 'popup off',
265    style.background === null ? null : `background ${style.background}`,
266  ]
267    .filter(line => line !== null)
268    .join(', ')
269}
270
271export type Outcome =
272  | { style: Style; text: string }
273  | { reset: true; text: string }
274  | { open: 'usage' | 'style' | 'context' | 'agents' }
275  | { text: string; isList?: true; isTest?: true; isWeek?: true; isCache?: true; isLapsed?: true }
276  | { export: true }
277  | { tidy: boolean }
278
279// What `/tokens <args>` asks for, given the style in force.
280export const apply = (style: Style, args: string): Outcome => {
281  const [first = '', second = '', third = ''] = args.trim().split(/\s+/)
282  const word = first.toLowerCase()
283  const value = second.toLowerCase()
284  const unknown = { text: `Unknown: "${args.trim()}". ${POINTER}; /tokens style has a button for each.` }
285
286  if (word === '') {
287    return { open: 'usage' }
288  }
289
290  if (word === 'style') {
291    return { open: 'style' }
292  }
293
294  // `context` alone opens the pane of what fills it; `hide context` (or, as
295  // before, `context off`) hides the part.
296  if (word === 'context' && value === '') {
297    return { open: 'context' }
298  }
299
300  if (word === 'agents') {
301    return { open: 'agents' }
302  }
303
304  if (word === 'help') {
305    return { text: HELP }
306  }
307
308  if (word === 'export') {
309    return { export: true }
310  }
311
312  if (word === 'tidy' && (value === '' || value === 'now')) {
313    return { tidy: value === 'now' }
314  }
315
316  if (word === 'list') {
317    return { text: `Now: ${describe(style)}. ${POINTER}.`, isList: true }
318  }
319
320  if (word === 'reset') {
321    return { reset: true, text: 'Style reset to the defaults.' }
322  }
323
324  const preset = Object.hasOwn(PRESETS, word) ? PRESETS[word] : undefined
325
326  if (preset !== undefined && value === '') {
327    return {
328      // Alerts and the crab switched off stay off: a look does not turn them back on.
329      style: {
330        ...style,
331        ...preset,
332        hidden: PARTS.filter(
333          part => preset.hidden.includes(part) || (APART.includes(part) && style.hidden.includes(part)),
334        ),
335        colors: {},
336      },
337      text: `Style: ${word}.`,
338    }
339  }
340
341  const layout = word === 'layout' ? value : value === '' ? word : ''
342
343  if (among(LAYOUTS, layout)) {
344    return { style: { ...style, layout }, text: `Layout: ${layout}.` }
345  }
346
347  if (word === 'palette' && among(PALETTE_NAMES, value)) {
348    return { style: { ...style, palette: value, colors: {} }, text: `Palette: ${value}.` }
349  }
350
351  if ((word === 'color' || word === 'colour') && value === 'background') {
352    if (third === 'off') {
353      return { style: { ...style, background: null }, text: 'Background: off.' }
354    }
355
356    if (!isHex(third)) {
357      return { text: 'A background is a hex colour, or off: color background #1f3b2d.' }
358    }
359
360    const ratio = contrastWithWhite(third)
361
362    return {
363      style: { ...style, background: third },
364      text:
365        ratio < READABLE
366          ? `Background: ${third}. White text reads poorly on it (${ratio.toFixed(1)}:1); a darker colour reads better.`
367          : `Background: ${third}.`,
368    }
369  }
370
371  if (word === 'color' || word === 'colour') {
372    const level = Object.hasOwn(LEVELS, value) ? LEVELS[value] : undefined
373
374    if (level === undefined || !isHex(third)) {
375      return { text: 'A colour is a level and a hex colour: color warn #c084fc.' }
376    }
377
378    return {
379      style: { ...style, colors: { ...style.colors, [level]: third } },
380      text: `Colour ${value}: ${third}.`,
381    }
382  }
383
384  if (word === 'labels' && among(LABEL_SETS, value)) {
385    return { style: { ...style, labels: value }, text: `Labels: ${value}.` }
386  }
387
388  if (word === 'alerts' && value === 'test') {
389    const which = third.toLowerCase()
390
391    // The cache's own notices, to see their buttons and hints at once: a
392    // press does what it says.
393    if (which === 'cache') {
394      return { text: 'Test cache warning raised: its buttons stand for two minutes, and a press does what it says.', isTest: true, isCache: true }
395    }
396
397    if (which === 'lapsed') {
398      return { text: 'The band shows a lapsed cache for a minute: its buttons do what they say.', isTest: true, isLapsed: true }
399    }
400
401    return which === 'week'
402      ? { text: 'Test alert raised for the weekly window.', isTest: true, isWeek: true }
403      : { text: 'Test alert raised.', isTest: true }
404  }
405
406  if (word === 'alerts' && among(NOTIFY, value)) {
407    return { style: { ...style, notify: value }, text: `Alerts go to: ${value}.` }
408  }
409
410  if (word === 'alerts' && value === '60' && (third === 'on' || third === 'off')) {
411    return {
412      style: { ...style, hasEarlyAlert: third === 'on' },
413      text: `Alert at 60%: ${third}.`,
414    }
415  }
416
417  // `budget project <name> <dollars|off>`: the name may have spaces, and
418  // `here` is the session's own folder, put in before the words come here.
419  if (word === 'budget' && value === 'project') {
420    const rest = args.trim().split(/\s+/).slice(2)
421    const last = (rest.at(-1) ?? '').toLowerCase()
422    const name = rest.slice(0, -1).join(' ')
423    const amount = Number(last)
424    const others = Object.fromEntries(Object.entries(style.projectBudgets).filter(([held]) => held !== name))
425
426    if (name === '' || (last !== 'off' && !isBudget(amount))) {
427      return { text: 'A project budget is a name and dollars a day, or off: budget project here 5.' }
428    }
429
430    return last === 'off'
431      ? { style: { ...style, projectBudgets: others }, text: `budget for ${name}: off.` }
432      : { style: { ...style, projectBudgets: { ...others, [name]: amount } }, text: `budget: $${amount.toFixed(2)} a day for ${name}.` }
433  }
434
435  if (word === 'budget' && value === 'month') {
436    const amount = Number(third)
437
438    if (third.toLowerCase() === 'off') {
439      return { style: { ...style, monthBudget: null }, text: 'monthly budget: off.' }
440    }
441
442    return third !== '' && isBudget(amount)
443      ? { style: { ...style, monthBudget: amount }, text: `budget: $${amount.toFixed(2)} a month.` }
444      : { text: 'A monthly budget is dollars a month, or off: budget month 200.' }
445  }
446
447  if (word === 'budget' && value === 'week') {
448    const amount = Number(third)
449
450    if (third.toLowerCase() === 'off') {
451      return { style: { ...style, weekBudget: null }, text: 'weekly budget: off.' }
452    }
453
454    return third !== '' && isBudget(amount)
455      ? { style: { ...style, weekBudget: amount }, text: `budget: $${amount.toFixed(2)} a week.` }
456      : { text: 'A weekly budget is dollars a week, or off: budget week 50.' }
457  }
458
459  if (word === 'budget') {
460    const amount = Number(value)
461
462    if (value === 'off') {
463      return { style: { ...style, budget: null }, text: 'budget: off.' }
464    }
465
466    return value !== '' && isBudget(amount)
467      ? { style: { ...style, budget: amount }, text: `budget: $${amount.toFixed(2)} a day.` }
468      : { text: 'A budget is dollars a day, or off: budget 10.' }
469  }
470
471  // `alerts popup off`; the word on its own, as before, still works.
472  const [outside, setting] = word === 'alerts' && among(OUTSIDE, value) ? [value, third.toLowerCase()] : [word, value]
473
474  if (outside === 'privacy') {
475    return setting === 'on' || setting === 'off'
476      ? { style: { ...style, isPrivate: setting === 'on' }, text: `privacy: ${setting}.` }
477      : { text: 'Privacy is on or off: alerts privacy off.' }
478  }
479
480  if (outside === 'popup') {
481    return setting === 'on' || setting === 'off'
482      ? { style: { ...style, hasPopup: setting === 'on' }, text: `popup: ${setting}.` }
483      : { text: 'The popup is on or off: alerts popup off.' }
484  }
485
486  // `quiet` alone is the preset, met above; with hours it is quiet hours.
487  if (outside === 'quiet') {
488    if (setting === 'off') {
489      return { style: { ...style, quiet: null }, text: 'quiet: off.' }
490    }
491
492    const [, from, to] = /^(\d{1,2})-(\d{1,2})$/.exec(setting) ?? []
493    const quiet = quietOf({ from: Number(from), to: Number(to) })
494
495    return from !== undefined && quiet !== null
496      ? { style: { ...style, quiet }, text: `quiet: ${hour(quiet.from)} to ${hour(quiet.to)}.` }
497      : { text: 'Quiet hours run from one hour of the day to another, or off: alerts quiet 22-8.' }
498  }
499
500  // `show|hide <part>`; `<part> on|off`, as before, still works.
501  const [part, state] = word === 'show' || word === 'hide' ? [value, word === 'show' ? 'on' : 'off'] : [word, value]
502
503  if (among(PARTS, part) && (state === 'on' || state === 'off')) {
504    const others = style.hidden.filter(held => held !== part)
505
506    return {
507      // Kept in the order the parts are listed, so two styles compare alike.
508      style: {
509        ...style,
510        hidden: state === 'on' ? others : PARTS.filter(held => held === part || others.includes(held)),
511      },
512      text: `${part}: ${state}.`,
513    }
514  }
515
516  return unknown
517}
518
hooks/band.tsx 643 lines
1// The band above the prompt: the figures, the crab with its news, the
2// Compact button, and under them what the plugins beneath draw.
3
4import { atom, read } from 'claude-code'
5import type { MatchedHook } from 'claude-code'
6import type { Motion, RingProps } from '../types'
7import { isCrowded } from './alerts'
8import { EMPTY, budgetShare, isPlanOf, today, usd } from './cost'
9import { cacheHints, cachePhase, cacheWord, hasLapsedActions, learnedTtl } from './cache'
10import { contextNews, mixOf } from './context'
11import { CRAB_ROWS, SHARP_CRAB_ROWS, WORK_PX, crabCells, crabSvg, daysOf, idleSvg, moodOf, napSvg, stageSvg, toneOf } from './crab'
12import { RING_PX, fit, told } from './fit'
13import type { Wish } from './fit'
14import { paletteFor, words } from './style'
15import { drawWindows } from './row'
16import { ringSvg } from './svg'
17import {
18  barCellsFor,
19  cellsOf,
20  countdown,
21  bandWidth,
22  eighths,
23  elapsed,
24  isQuiet,
25  layout,
26  percentage,
27  pie,
28  remaining,
29  tone,
30  turnsLeft,
31} from './usage'
32import { note, trouble } from './measure'
33import { act, cacheSaid as pricing, compact, heard, hush } from './notices'
34import { cellsOfSplit, splitSvg } from './advice'
35import type { ChoiceKey } from './advice'
36import {
37  BUDGET,
38  COMPACT,
39  CONTEXT_PANE,
40  CRAB_MIN_COLUMNS,
41  CRAB_MIN_ROWS,
42  FRESH,
43  HINT_MIN_ROWS,
44  NEAR_MS,
45  RESET_MS,
46  SLEEP_MS,
47  SOON_TURNS,
48  saneBreakdown,
49  scope,
50  shown,
51  styleOf,
52} from './state'
53
54// The hover groups that open a hint at the band's top: a pointer on the
55// thing lights its group, which reveals the hint.
56const HINT = 'hint-'
57const HINT_FRESH = `${HINT}fresh`
58const HINT_COMPACT = `${HINT}compact`
59const HINT_CACHE = `${HINT}cache`
60
61// Not `snapshot`: a session reloaded from 0.1.0 still holds that key in its old shape.
62const snapshot = atom({ plugin: 'token-usage', key: 'usage' } as const, null)
63
64const now = atom({ plugin: 'token-usage', key: 'now' } as const, 0)
65
66// The windows that have just reset, while their reset plays.
67const fresh = atom({ plugin: 'token-usage', key: 'fresh' } as const, {})
68
69// The notice the crab is bringing; null once it has left or was sent away.
70const saying = atom({ plugin: 'token-usage', key: 'saying' } as const, null)
71
72// Dollars by day, for every session, held here to draw; the store keeps what
73// of the day's budget was told, and the days counted before sessions had
74// files of their own.
75const ledger = atom({ plugin: 'token-usage', key: 'ledger' } as const, EMPTY)
76
77// The context fill at the end of each turn, for its forecast; this many kept.
78const growth = atom({ plugin: 'token-usage', key: 'growth' } as const, [])
79
80// The prompt cache: the pause it counts down, and the pauses it was judged
81// on, every session's (how long it lasts is the account's).
82const idleSince = atom({ plugin: 'token-usage', key: 'idleSince' } as const, null)
83
84// When the crab last had nothing to do: its nap counts from here.
85const restSince = atom({ plugin: 'token-usage', key: 'restSince' } as const, null)
86
87// Until when `/tokens alerts test lapsed` shows a lapsed cache.
88const cacheTest = atom({ plugin: 'token-usage', key: 'cacheTest' } as const, 0)
89
90const pauses = atom({ plugin: 'token-usage', key: 'pauses' } as const, [])
91
92// What fills the context, as /context breaks it down.
93const breakdown = atom({ plugin: 'token-usage', key: 'breakdown' } as const, null)
94
95// The bar of what fills the context, in cells: the terminal's after CTX, the
96// desktop's under the ring.
97const TERMINAL_CELLS = 10
98const DESKTOP_CELLS = 8
99// Where a heavy turn's tokens went: a bar this wide on the desktop, this
100// many cells in a terminal.
101const SPLIT_PX = 220
102const SPLIT_CELLS = 20
103// The terminal's second row starts under the context's bar, and names it.
104const SECOND_INDENT = 6
105const MIX_WORD = ' context'
106
107type DrawBandHook = MatchedHook<'ui.render', { component: 'AbovePrompt' }>
108
109export async function drawBand($: Parameters<DrawBandHook>[0], e: Parameters<DrawBandHook>[1], next: Parameters<DrawBandHook>[2]) {
110    const usage = await read($, snapshot)
111
112    if (e.props.hasSurvey || usage === null) {
113      return next(e)
114    }
115
116    const { Box, Text, Button } = $.ui.resolve(e)
117    const { percent } = usage
118    const at = Math.max(usage.at, await read($, now))
119    const style = await styleOf($, scope.base)
120    // A context this full gets a way out beside it: one press compacts. One
121    // that fills within a few turns gets it sooner, as the primary.
122    const toGo = turnsLeft(await read($, growth))
123    const isSoon = toGo !== null && toGo <= SOON_TURNS
124    // While no turn runs, the cache counts down the pause since the last.
125    const paused = e.props.isWorking ? null : await read($, idleSince)
126    const ttl = learnedTtl(await read($, pauses)).ttl
127    // A test of the lapse shows it for a minute, for the context as it is.
128    const isTestLapse = !e.props.isWorking && (await read($, cacheTest)) > at
129    // A resumed session the engine called lapsed is lapsed, where the cache's
130    // time is not learned yet.
131    const cachePart =
132      isTestLapse || (paused?.lapsed === true && ttl === null)
133        ? { phase: 'gone' as const, left: 0 }
134        : paused === null
135          ? null
136          : cachePhase(at - paused.at, ttl)
137    const idleTokens = paused?.tokens ?? (isTestLapse ? usage.tokens : null)
138    // A large context whose cache has lapsed: the next turn re-reads all of
139    // it, so the band offers to start fresh or compact first.
140    const isLapsed = hasLapsedActions(cachePart, idleTokens)
141    const hasCompact = isCrowded(percent) || isSoon || isLapsed
142    const compactPress = (
143      <Button
144        key="compact"
145        label={COMPACT}
146        {...(isSoon ? { variant: 'primary' as const } : {})}
147        {...(isLapsed ? { hover: { scope: HINT_COMPACT } } : {})}
148        onPress={() => compact($, e.props.isWorking)}
149      />
150    )
151    const press = !hasCompact ? null : isLapsed ? (
152      <Box gap={1}>
153        <Button key="fresh" label={FRESH} hover={{ scope: HINT_FRESH }} onPress={() => void $.command.run({ command: 'clear' })} />
154        {compactPress}
155      </Box>
156    ) : (
157      compactPress
158    )
159    const action = !hasCompact ? null : isLapsed ? `${FRESH}    ${COMPACT}` : COMPACT
160    // In quiet hours the band says until when; the crab sleeps through them.
161    const quiet =
162      style.quiet !== null && isQuiet(style.quiet, at)
163        ? `quiet until ${String(style.quiet.to).padStart(2, '0')}:00`
164        : null
165    const speaking = heard(await read($, saying), style, at)
166    const talk = speaking?.notice ?? null
167    // Sent away or out of time, the crab walks off alone: its words and
168    // buttons go at once, so a press shows and cannot be pressed again.
169    const isStaying = speaking?.phase === 'in'
170    // Two minutes with nothing to do, since a turn ended or the session began
171    // (a /clear too), put the crab to sleep where the ring is, as a turn puts
172    // it to work there. News wakes it; quiet hours keep their own nap.
173    const rested = e.props.isWorking ? null : await read($, restSince)
174    const isAsleep =
175      rested !== null && at - rested >= SLEEP_MS && talk === null && quiet === null && !style.hidden.includes('crab')
176    // The desktop fits its own row: it is handed the band unnarrowed. A
177    // terminal's row gives the button its room first: `[ Compact ]` and a gap,
178    // and the sleeping crab the cell its second z takes past the ring's.
179    const columns =
180      e.surface === 'desktop'
181        ? Number.POSITIVE_INFINITY
182        : e.props.bodyColumns - (action === null ? 0 : action.length + 7) - (isAsleep ? 1 : 0)
183    // The band says what the day has cost, in every session; the pane has
184    // the session's own total.
185    const kept = await read($, ledger)
186    // On a subscription the dollars are the API's price, not a bill.
187    const cost = usage.usd === null ? null : `${usd(today(kept, at))} ${isPlanOf(usage.limits) ? 'API today' : 'today'}`
188    const band = shown(layout(usage, at, columns, cost), style)
189    const cacheSaid =
190      quiet !== null || cachePart === null
191        ? null
192        : {
193            text: cacheWord(cachePart, idleTokens),
194            color: cachePart.phase === 'gone' ? ('error' as const) : cachePart.phase === 'near' ? ('warning' as const) : null,
195          }
196    // The pointer on it says what a lapse costs, beneath the band's top.
197    const cacheText = (said: { text: string; color: 'error' | 'warning' | null }) =>
198      said.color === null ? (
199        <Text dimColor hover={{ scope: HINT_CACHE, underline: true }}>
200          {said.text}
201        </Text>
202      ) : (
203        <Text color={said.color} hover={{ scope: HINT_CACHE, underline: true }}>
204          {said.text}
205        </Text>
206      )
207    const hasContext = !style.hidden.includes('context')
208    // What fills the context, while the part is shown and a count is held: a
209    // bar of its rows, and a word where there is something to do, which
210    // opens the pane of it.
211    const filled = style.hidden.includes('breakdown') ? null : await read($, breakdown)
212    const held = filled === null ? null : saneBreakdown(filled.value)
213    const news = held === null ? null : contextNews(held.categories, held.rawMaxTokens, isCrowded(percent))
214    const mix = (cells: number, glyph: string) =>
215      held === null || !hasContext
216        ? null
217        : mixOf(held.categories, cells).map(({ name, color, count }) => (
218            <Text key={name} color={color}>
219              {glyph.repeat(count)}
220            </Text>
221          ))
222    const newsPress = news === null ? null : (
223      <Button
224        key="context-news"
225        plain
226        onPress={() => void $.ui.open({ id: CONTEXT_PANE, title: 'Token usage · context' })}
227      >
228        <Text color={news.tone}>{news.text}</Text>
229      </Button>
230    )
231    const glyph = percent === null ? '○' : pie(percent)
232    const color = percent === null ? 'inactive' : tone(percent)
233    let fill = <Text color={color}>{glyph}</Text>
234
235    // Only the terminal spins a glyph in a client region; elsewhere it rests.
236    if (e.surface === 'terminal' && isAsleep) {
237      fill = <Text dimColor>ᶻᶻ</Text>
238    } else if (e.surface === 'terminal') {
239      const { Client } = $.ui.resolve(e)
240      const props: RingProps = { glyph, color, isWorking: e.props.isWorking }
241      fill = <Client key="ctx" module="./ring.tsx" width={1} props={props} />
242    }
243
244    // The band is shared: draw the plugins beneath under the usage row.
245    const beneath = await next(e)
246    const problems = await trouble($, at)
247
248    // The desktop draws vectors: the context as a ring, each window in the
249    // layout the style names.
250    if (e.surface === 'desktop') {
251      const { Svg } = $.ui.resolve(e)
252      const palette = paletteFor(style)
253      const wished = words(style)
254      const snug = words({ ...style, layout: 'compact' })
255      const napped = napSvg(`Crab asleep: ${quiet ?? ''}`, palette)
256      const hasCountdown = !style.hidden.includes('countdown')
257      // While a turn runs the crab works at its laptop where the ring was;
258      // until it sleeps it stands by there, unless it is out with news.
259      const isAtWork = e.props.isWorking && !style.hidden.includes('crab')
260      const isIdle = !e.props.isWorking && !isAsleep && talk === null && quiet === null && !style.hidden.includes('crab')
261      const renewed = await read($, fresh)
262      const limits = usage.limits.map(limit => {
263        const left = remaining(limit, at)
264        const reset = renewed[limit.kind]
265        const motion: Motion | undefined =
266          reset !== undefined && at - reset.at < RESET_MS
267            ? { kind: 'reset', from: reset.from }
268            : left !== null && left > 0 && left <= NEAR_MS
269              ? { kind: 'near' }
270              : undefined
271
272        return {
273          motion,
274          name: wished.name(limit.kind),
275          brief: snug.name(limit.kind),
276          cells: cellsOf(limit.kind),
277          percent: limit.percentUsed,
278          used: percentage(limit.percentUsed).trim(),
279          gone: elapsed(limit, at),
280          // A window that names no reset has no countdown to draw.
281          clock: hasCountdown && left !== null ? countdown(left) : null,
282        }
283      })
284      const wish: Wish = {
285        layout: style.layout,
286        context: hasContext
287          ? {
288              used: percentage(percent).trim(),
289              word: wished.context,
290              figure: isAtWork ? WORK_PX.width : isAsleep || isIdle ? napped.width : RING_PX,
291            }
292          : null,
293        windows: limits,
294        usedWord: wished.used,
295        leftWord: wished.left,
296        eta: band.eta,
297        cost: band.cost,
298        ...(style.budget === null ? {} : { costFigure: RING_PX }),
299        action,
300        ...(quiet !== null
301          ? { note: { text: [quiet, news?.text].filter(Boolean).join('     '), figure: style.hidden.includes('crab') ? 0 : napped.width } }
302          : cacheSaid !== null || news !== null
303            ? { note: { text: [cacheSaid?.text, news?.text].filter(Boolean).join('     '), figure: 0 } }
304            : {}),
305      }
306      const fitted = fit(wish, e.props.bodyColumns)
307      const isSnug = fitted.layout !== style.layout
308      const named = isSnug ? snug : wished
309      const ringed = ringSvg(percent, named.context, e.props.isWorking, palette)
310      const asleep = napSvg(`${ringed.alt}, Crab asleep`, palette)
311      const idle = idleSvg(`${ringed.alt}, Crab standing by`, palette)
312      const context = isAtWork
313        ? { drawn: crabSvg('work', `${ringed.alt}, working`, palette), ...WORK_PX }
314        : isAsleep
315          ? { drawn: asleep, width: asleep.width, height: asleep.height }
316          : isIdle
317            ? { drawn: idle, width: idle.width, height: idle.height }
318            : { drawn: ringed, width: RING_PX, height: RING_PX }
319
320      scope.fitting = told(wish, fitted, e.props.bodyColumns)
321      const figure = (
322        drawn: { source: string; alt: string },
323        width: number,
324        height: number,
325      ) => <Svg source={drawn.source} alt={drawn.alt} width={width} height={height} />
326      const windows = limits.slice(0, fitted.windows).map(window => ({
327        ...window,
328        name: isSnug ? window.brief : window.name,
329        clock: fitted.hasClock ? window.clock : null,
330      }))
331      const { hasWords } = fitted
332      // The crab stands on its window's own ground: the cells of the 5-hour or
333      // the weekly window, with now marked, and the weekly days named.
334      const walked = talk?.window === undefined ? undefined : talk.window
335      const limit = walked === undefined ? undefined : usage.limits.find(({ kind }) => kind === walked)
336      const stage =
337        speaking === null || talk === null
338          ? null
339          : stageSvg(
340              moodOf(talk.kind),
341              talk.bar.value * 100,
342              `Crab: ${talk.title}`,
343              palette,
344              speaking.phase,
345              walked === undefined
346                ? null
347                : {
348                    cells: cellsOf(walked),
349                    gone: limit === undefined ? null : elapsed(limit, at),
350                    days: walked === 'seven_day' ? daysOf(limit?.resetsAt ?? null, cellsOf(walked)) : null,
351                  },
352            )
353
354      // What the crab offers: a button each, the best first, digits for keys.
355      const offered = (talk?.choices ?? []).map(({ key, label, hint }, index) => (
356        <Button
357          key={`choice-${key}`}
358          label={label}
359          hotkey={String(index + 1)}
360          {...(index === 0 ? { variant: 'primary' as const } : {})}
361          {...(hint === undefined ? {} : { hover: { scope: `${HINT}choice-${key}` } })}
362          onPress={() => act($, key as ChoiceKey, talk?.bar.label ?? '')}
363        />
364      ))
365      const split = talk?.split ?? []
366      // What a choice or the cache's word does and costs, each hidden until the
367      // pointer rests on it. They open at the band's top: the band grows up
368      // from the prompt, so what the pointer rests on stays where it is.
369      const hints = cacheHints(idleTokens, await pricing($, ttl, paused?.usd ?? null))
370      const hinted = [
371        ...(isStaying
372          ? (talk?.choices ?? []).flatMap(({ key, label, hint }) => (hint === undefined ? [] : [{ scope: `${HINT}choice-${key}`, label, hint }]))
373          : []),
374        ...(isLapsed
375          ? [
376              { scope: HINT_FRESH, label: FRESH, hint: hints.fresh },
377              { scope: HINT_COMPACT, label: COMPACT, hint: hints.compactLapsed },
378            ]
379          : []),
380        ...(cachePart === null
381          ? []
382          : [
383              cachePart.phase === 'gone'
384                ? { scope: HINT_CACHE, label: 'The cache has lapsed', hint: hints.gone }
385                : { scope: HINT_CACHE, label: 'Why a big context costs', hint: hints.why },
386            ]),
387      ]
388
389      // The row is fitted by estimate, so two guards stay: no group gives up
390      // width (squeezed, its text spills over the next one), and a row still
391      // too wide wraps.
392      return (
393        // A background of the person's own fills the band inside the app's frame:
394        // to its sides, a column past the band's own each side, the padding giving
395        // the row its width back. The engine clips rows at the band's top and
396        // bottom, so the frame's strips there stay.
397        <Box flexDirection="column" {...(style.background === null ? {} : { backgroundColor: style.background, marginX: -1, paddingX: 1, paddingY: 1 })}>
398          {hinted.map(({ scope, label, hint }) => (
399            <Box key={scope} flexDirection="column" display="none" hover={{ scope, display: 'flex' }} marginBottom={1}>
400              <Text weight="semibold">{label}</Text>
401              <Text dimColor wrap="wrap">
402                {hint}
403              </Text>
404            </Box>
405          ))}
406          {stage !== null && talk !== null && (
407            <Box gap={2} alignItems="center" flexWrap="wrap">
408              <Box flexShrink={0}>{figure(stage, stage.width, stage.height)}</Box>
409              {/* The words give way and wrap in a narrow band; the OK keeps its room. */}
410              {isStaying && (
411                <Box flexDirection="column" flexShrink={1} minWidth={0}>
412                  <Text weight="semibold" wrap="wrap">
413                    {talk.title}
414                  </Text>
415                  {talk.text !== '' && (
416                    <Text wrap="wrap" {...(talk.tone === undefined || talk.tone === null ? { dimColor: true } : { color: talk.tone })}>
417                      {talk.text}
418                    </Text>
419                  )}
420                  {split.length > 0 && (
421                    <Box flexDirection="column">
422                      {figure(splitSvg(split, SPLIT_PX), SPLIT_PX, 6)}
423                      <Text dimColor wrap="wrap">
424                        {split.map(({ name, share }) => `${name} ${Math.round(share * 100)}%`).join(' · ')}
425                      </Text>
426                    </Box>
427                  )}
428                </Box>
429              )}
430              {isStaying && (
431                <Box flexShrink={0} gap={1} flexWrap="wrap">
432                  {offered}
433                  <Button key="crab-ok" label="OK" onPress={() => hush($)} />
434                </Box>
435              )}
436            </Box>
437          )}
438          <Box columnGap={2} alignItems="center" flexWrap="wrap">
439            {hasContext && (
440              <Box flexDirection="column" flexShrink={0}>
441                <Box gap={1} alignItems="center">
442                  {figure(context.drawn, context.width, context.height)}
443                  <Text weight="semibold">{percentage(percent).trim()}</Text>
444                  {fitted.hasContextWord && <Text dimColor>{named.context}</Text>}
445                </Box>
446                {fitted.hasContextWord && held !== null && <Box>{mix(DESKTOP_CELLS, '▬')}</Box>}
447              </Box>
448            )}
449            {press !== null && <Box flexShrink={0}>{press}</Box>}
450            {drawWindows({ Box, Text, Svg }, fitted.layout, windows, palette, named, hasWords)}
451            {fitted.hasEta && band.eta !== null && (
452              <Box flexShrink={0}>
453                <Text color="warning">{band.eta}</Text>
454              </Box>
455            )}
456            {fitted.hasCost && band.cost !== null && (
457              <Box gap={1} alignItems="center" flexShrink={0}>
458                {style.budget !== null &&
459                  figure(
460                    ringSvg(budgetShare(kept, at, style.budget), BUDGET, false, palette),
461                    RING_PX,
462                    RING_PX,
463                  )}
464                <Text>{band.cost}</Text>
465              </Box>
466            )}
467            {fitted.hasCost && quiet !== null && (
468              <Box gap={1} alignItems="center" flexShrink={0}>
469                {!style.hidden.includes('crab') && figure(napped, napped.width, napped.height)}
470                <Text dimColor>{quiet}</Text>
471              </Box>
472            )}
473            {fitted.hasCost && cacheSaid !== null && <Box flexShrink={0}>{cacheText(cacheSaid)}</Box>}
474            {fitted.hasCost && newsPress !== null && <Box flexShrink={0}>{newsPress}</Box>}
475          </Box>
476          {problems.map(line => (
477            <Text dimColor>{line}</Text>
478          ))}
479          {beneath}
480        </Box>
481      )
482    }
483
484    // A terminal row draws each figure as a bar of eighths in the room it has
485    // left, the window's time gone marked in it; a row with none keeps the
486    // glyphs.
487    const terminalBand = band
488    const barCells = barCellsFor(columns - bandWidth(band), band.limits.length)
489    // With a second row to spare, what fills the context, the cache and the
490    // word of what to do go beneath the figures; without, they go after them
491    // where they fit, the most useful first.
492    const hasSecondRow = e.props.maxRows >= 2
493    const secondColumns = e.props.bodyColumns - SECOND_INDENT
494    let taken = bandWidth(terminalBand) + (barCells === 0 ? 0 : barCells + 1 + (barCells - 1) * terminalBand.limits.length)
495    let below = -3
496    const room = (wide: number, gap = 3) => {
497      if (hasSecondRow) {
498        return below + gap + wide <= secondColumns ? ((below += gap + wide), true) : false
499      }
500
501      return taken + gap + wide <= columns ? ((taken += gap + wide), true) : false
502    }
503    const hasNews = news !== null && room(news.text.length)
504    const hasCache = cacheSaid !== null && room(cacheSaid.text.length)
505    const hasMix = held !== null && hasContext && room(TERMINAL_CELLS + (hasSecondRow ? MIX_WORD.length : 0), hasSecondRow ? 3 : 1)
506    const barOf = (spent: number | null, gone: number | null, color: string) =>
507      eighths(spent, gone, barCells).map(({ text, isNow }) => (
508        <Text color={color} backgroundColor={isNow ? 'inactive' : 'subtle'}>
509          {text}
510        </Text>
511      ))
512    const hasCrabRows =
513      e.props.maxRows >= CRAB_MIN_ROWS && e.props.bodyColumns >= CRAB_MIN_COLUMNS
514    // The finer crab where the band has its rows and the two beneath it.
515    const isSharp = e.props.maxRows >= SHARP_CRAB_ROWS + 2
516    const crabDrawn = isSharp
517      ? crabCells().map(runs => (
518          <Box>
519            {runs.map(({ text, color, backgroundColor }) => (
520              <Text {...(color === null ? {} : { color })} {...(backgroundColor === null ? {} : { backgroundColor })}>
521                {text}
522              </Text>
523            ))}
524          </Box>
525        ))
526      : CRAB_ROWS.map(row => <Text color={talk === null ? undefined : toneOf(talk.kind)}>{row}</Text>)
527    const second = hasSecondRow && (hasMix || hasCache || hasNews) ? (
528      <Box gap={3} paddingLeft={SECOND_INDENT}>
529        {hasMix && (
530          <Box gap={1}>
531            <Box>{mix(TERMINAL_CELLS, '▆')}</Box>
532            <Text dimColor>{MIX_WORD.trim()}</Text>
533          </Box>
534        )}
535        {hasCache && cacheSaid !== null && cacheText(cacheSaid)}
536        {hasNews && newsPress}
537      </Box>
538    ) : null
539
540    return (
541      <Box flexDirection="column">
542        {/* The words and buttons stand where the crab has no room beside them. */}
543        {talk !== null && (hasCrabRows || isStaying) && (
544          <Box gap={2}>
545            {hasCrabRows && (
546              <Box flexDirection="column" flexShrink={0}>
547                {crabDrawn}
548              </Box>
549            )}
550            {isStaying && (
551              <Box flexDirection="column" flexShrink={1} minWidth={0} {...(isSharp ? { paddingTop: 1 } : {})}>
552                <Text weight="semibold" wrap="wrap">
553                  {talk.title}
554                </Text>
555                {talk.text !== '' && (
556                  <Text wrap="wrap" {...(talk.tone === undefined || talk.tone === null ? { dimColor: true } : { color: talk.tone })}>
557                    {talk.text}
558                  </Text>
559                )}
560                {(talk.split ?? []).length > 0 && (
561                  <Box columnGap={1} flexWrap="wrap">
562                    <Box flexShrink={0}>
563                      {cellsOfSplit(talk.split ?? [], SPLIT_CELLS).map(({ text, color: shade }) => (
564                        <Text color={shade}>{text}</Text>
565                      ))}
566                    </Box>
567                    <Box flexShrink={1} minWidth={0}>
568                      <Text dimColor wrap="wrap">
569                        {(talk.split ?? []).map(({ name, share }) => `${name} ${Math.round(share * 100)}%`).join(' · ')}
570                      </Text>
571                    </Box>
572                  </Box>
573                )}
574                {(talk.choices ?? []).length > 0 && (
575                  <Box columnGap={1} flexWrap="wrap">
576                    {(talk.choices ?? []).map(({ key, label }, index) => (
577                      <Button
578                        key={`choice-${key}`}
579                        label={`${index + 1} ${label}`}
580                        hotkey={String(index + 1)}
581                        {...(index === 0 ? { variant: 'primary' as const } : {})}
582                        onPress={() => act($, key as ChoiceKey, talk.bar.label)}
583                      />
584                    ))}
585                    <Button key="crab-ok" label="OK" onPress={() => hush($)} />
586                  </Box>
587                )}
588                {/* Narrow, they would wrap the figures out of view. */}
589                {e.props.maxRows >= HINT_MIN_ROWS && e.props.bodyColumns >= CRAB_MIN_COLUMNS &&
590                  (talk.choices ?? []).map(({ key, label, hint }, index) =>
591                    hint === undefined ? null : (
592                      <Text key={`hint-${key}`} dimColor wrap="wrap">
593                        {`${index + 1} ${label}: ${hint}`}
594                      </Text>
595                    ),
596                  )}
597              </Box>
598            )}
599          </Box>
600        )}
601        <Box gap={3}>
602          {hasContext && (
603            <Box gap={1}>
604              {fill}
605              <Text dimColor>CTX</Text>
606              {barCells > 0 && <Box>{barOf(percent, null, color)}</Box>}
607              <Text {...(barCells > 0 ? { weight: 'semibold' as const } : {})}>{barCells > 0 ? percentage(percent).trim() : percentage(percent)}</Text>
608              {!hasSecondRow && hasMix && <Box>{mix(TERMINAL_CELLS, '▆')}</Box>}
609            </Box>
610          )}
611          {press}
612          {terminalBand.limits.map((limit, index) => (
613            <Box gap={1}>
614              {barCells === 0 && <Text color={limit.tone}>{limit.glyph}</Text>}
615              <Text dimColor>{limit.label}</Text>
616              {barCells > 0 && (
617                <Box>
618                  {barOf(
619                    usage.limits[index]?.percentUsed ?? null,
620                    usage.limits[index] === undefined ? null : elapsed(usage.limits[index], at),
621                    limit.tone,
622                  )}
623                </Box>
624              )}
625              <Text {...(barCells > 0 ? { weight: 'semibold' as const } : {})}>{barCells > 0 ? limit.percent.trim() : limit.percent}</Text>
626              {limit.left !== null && <Text dimColor>{limit.left}</Text>}
627            </Box>
628          ))}
629          {terminalBand.eta !== null && <Text color="warning">{terminalBand.eta}</Text>}
630          {terminalBand.cost !== null && <Text>{terminalBand.cost}</Text>}
631          {quiet !== null && <Text dimColor>{quiet}</Text>}
632          {!hasSecondRow && hasCache && cacheSaid !== null && cacheText(cacheSaid)}
633          {!hasSecondRow && hasNews && newsPress}
634        </Box>
635        {second}
636        {problems.map(line => (
637          <Text dimColor>{line}</Text>
638        ))}
639        {beneath}
640      </Box>
641    )
642}
643
hooks/ledger.ts 376 lines
1// The ledger and the counts on the disk: each session's own file, every
2// session's summed, the budget's news, the context's breakdown, export, tidy.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface } from 'claude-code'
6import type { Share } from '../types'
7import {
8  EMPTY,
9  NO_SHARE,
10  budgetNews,
11  isPlanOf,
12  saneMonthTold,
13  saneWeekTold,
14  monthNews,
15  weekNews,
16  exportCsv,
17  dayOf,
18  pool,
19  saneLedger,
20  saneShare,
21  spend,
22  projectNews,
23  saneProjectTold,
24} from './cost'
25import { keepSetUp, setUpOf } from './context'
26import { told } from './fit'
27import {
28  LEDGER_KEY,
29  NOTIFY_MS,
30  SET_UPS_KEY,
31  SHARES_DIR,
32  STALE_MS,
33  WEEK_TOLD_KEY,
34  MONTH_TOLD_KEY,
35  others,
36  saneBreakdown,
37  saneSetUps,
38  PROJECT_TOLD_KEY,
39  scope,
40} from './state'
41
42const last = atom({ plugin: 'token-usage', key: 'last' } as const, null)
43
44const now = atom({ plugin: 'token-usage', key: 'now' } as const, 0)
45
46// Dollars by day, for every session, held here to draw; the store keeps what
47// of the day's budget was told, and the days counted before sessions had
48// files of their own.
49const ledger = atom({ plugin: 'token-usage', key: 'ledger' } as const, EMPTY)
50// The last reading: its windows say whether the session is on a subscription.
51const snapshot = atom({ plugin: 'token-usage', key: 'usage' } as const, null)
52
53const mine = atom({ plugin: 'token-usage', key: 'mine' } as const, NO_SHARE)
54
55// The file `mine` belongs to. After /clear or a resume the process goes on
56// under another id with no session.start: the share is then that id's own.
57const mineName = atom({ plugin: 'token-usage', key: 'mineName' } as const, '')
58
59// What the store counted before sessions had files of their own.
60const before = atom({ plugin: 'token-usage', key: 'before' } as const, NO_SHARE)
61
62// Tokens by day, by project, and the heaviest turns of the day: shared by
63// every session, like the ledger. The pane counts in tokens; the band, in
64// dollars.
65const projects = atom({ plugin: 'token-usage', key: 'projects' } as const, {})
66
67const models = atom({ plugin: 'token-usage', key: 'models' } as const, {})
68
69// The subagents' tokens and runs by agent type, for every session.
70const agents = atom({ plugin: 'token-usage', key: 'agents' } as const, { agents: {}, runs: {} })
71
72const tokenDays = atom({ plugin: 'token-usage', key: 'tokenDays' } as const, {})
73
74// What fills the context, as /context breaks it down.
75const breakdown = atom({ plugin: 'token-usage', key: 'breakdown' } as const, null)
76
77// The set-up of each day as last counted, for every session: the latest wins.
78const setUps = atom({ plugin: 'token-usage', key: 'setUps' } as const, {})
79
80// The last hook of this mod that failed, and when.
81const failed = atom({ plugin: 'token-usage', key: 'failed' } as const, null)
82
83// Asks the engine for the breakdown, counted the free way or exactly, and
84// holds it for the panes.
85export async function countContext($: EngineInterface, detail: 'summary' | 'full') {
86  try {
87    const usage = await $.session.usage({ breakdown: detail })
88    const value = saneBreakdown(usage.context.breakdown)
89
90    if (value !== null) {
91      const at = await $.clock.now()
92      const setUp = setUpOf(value.categories)
93
94      await update($, breakdown, () => ({ detail, at, value }))
95      await update($, setUps, held => keepSetUp(held, dayOf(at), setUp))
96
97      // Kept for every session; a store that refuses leaves today's in hand.
98      try {
99        const kept = keepSetUp(saneSetUps(await $.store.get(SET_UPS_KEY)), dayOf(at), setUp)
100
101        await $.store.set(SET_UPS_KEY, kept)
102        await update($, setUps, () => kept)
103      } catch {
104        // Nothing to do: the next count tries again.
105      }
106    }
107
108    return usage
109  } catch {
110    return null
111  }
112}
113
114// The panes keep up by themselves: once one has asked for the breakdown, it
115// is counted again, the free way, whenever the context moves. The band asks
116// for it too (`isWanted`) while it draws the bar; a session that wants it
117// nowhere counts nothing.
118export async function recount($: EngineInterface, isWanted = false) {
119  return !isWanted && (await read($, breakdown)) === null ? null : countContext($, 'summary')
120}
121
122// This session's own file, or null where there is no home folder or no id.
123export async function ownFile($: EngineInterface) {
124  try {
125    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
126    const id = (await $.session.id()).replace(/[^A-Za-z0-9_-]/g, '')
127
128    return typeof home === 'string' && home !== '' && id !== ''
129      ? { dir: `${home}/${SHARES_DIR}`, name: `${id}.json` }
130      : null
131  } catch {
132    return null
133  }
134}
135
136// Makes `mine` the share of the session as it is now: one that changed its
137// id starts over from the file of the new one, or from nothing.
138export async function adopt($: EngineInterface) {
139  const own = await ownFile($)
140
141  if (own === null || own.name === (await read($, mineName))) {
142    return
143  }
144
145  let share = NO_SHARE
146
147  try {
148    share = saneShare(JSON.parse(String(await $.fs.read(`${own.dir}/${own.name}`))))
149  } catch {
150    // A new session: it has no file yet.
151  }
152
153  await update($, mine, () => share)
154  await update($, mineName, () => own.name)
155}
156
157// This session's share goes to its file. Where it cannot be written the
158// share is still held, and the next write takes it all.
159export async function keep($: EngineInterface) {
160  const own = await ownFile($)
161
162  if (own !== null) {
163    try {
164      await $.fs.write(`${own.dir}/${own.name}`, JSON.stringify(await read($, mine)))
165    } catch {
166      // Nothing to do: see above.
167    }
168  }
169}
170
171// Every session's share summed: the store's from before, this session's, and
172// each other session's file. A file that cannot be read is passed over, or
173// counts as it was last read.
174export async function gather($: EngineInterface, at: number) {
175  const own = await ownFile($)
176  const theirs: Share[] = []
177
178  if (own !== null) {
179    try {
180      const listed = new Set<string>()
181
182      for (const entry of await $.fs.list(own.dir)) {
183        if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === own.name) {
184          continue
185        }
186
187        if (entry.mtimeMs < at - STALE_MS) {
188          continue
189        }
190
191        listed.add(entry.name)
192
193        if (others.get(entry.name)?.mtimeMs !== entry.mtimeMs) {
194          try {
195            const text = String(await $.fs.read(`${own.dir}/${entry.name}`))
196
197            others.set(entry.name, { mtimeMs: entry.mtimeMs, share: saneShare(JSON.parse(text)) })
198          } catch {
199            // Half written, or gone: read again once it changes.
200          }
201        }
202
203        const held = others.get(entry.name)
204
205        if (held !== undefined) {
206          theirs.push(held.share)
207        }
208      }
209
210      for (const name of [...others.keys()].filter(name => !listed.has(name))) {
211        others.delete(name)
212      }
213    } catch {
214      // No folder yet: no session has counted in one.
215    }
216  }
217
218  const sum = pool([await read($, before), await read($, mine), ...theirs], at)
219
220  await update($, ledger, held => ({ ...held, days: sum.days }))
221  await update($, tokenDays, () => sum.tokens)
222  await update($, projects, () => sum.projects)
223  await update($, models, () => sum.models)
224  await update($, agents, () => ({ agents: sum.agents ?? {}, runs: sum.runs ?? {} }))
225
226  return sum
227}
228
229// What a reading cost goes on the day in this session's own share; the day
230// is then read from every session's. Says what of the day's budget is news,
231// noted in the store so that each line is said once a day by any session.
232export async function pay(
233  $: EngineInterface,
234  at: number,
235  amount: number,
236  budget: number | null,
237  weekBudget: number | null,
238  projectBudgets: Readonly<Record<string, number>> = {},
239  monthBudget: number | null = null,
240) {
241  await adopt($)
242  await update($, mine, held => spend(held, at, amount, scope.project))
243  await keep($)
244
245  const sum = await gather($, at)
246  const kept = saneLedger(await $.store.get(LEDGER_KEY))
247  const isPlan = isPlanOf((await read($, snapshot))?.limits ?? [])
248  const judged = budgetNews({ days: sum.days, told: kept.told }, at, budget, isPlan)
249
250  await $.store.set(LEDGER_KEY, { days: kept.days, told: judged.ledger.told })
251  await update($, ledger, () => judged.ledger)
252
253  const weekly = weekNews(sum.days, at, weekBudget, saneWeekTold(await $.store.get(WEEK_TOLD_KEY)), isPlan)
254
255  if (weekly.notices.length > 0) {
256    await $.store.set(WEEK_TOLD_KEY, weekly.told)
257  }
258
259  const monthly = monthNews(sum.days, at, monthBudget, saneMonthTold(await $.store.get(MONTH_TOLD_KEY)), isPlan)
260
261  if (monthly.notices.length > 0) {
262    await $.store.set(MONTH_TOLD_KEY, monthly.told)
263  }
264
265  const projected = projectNews(sum.spent, at, projectBudgets, saneProjectTold(await $.store.get(PROJECT_TOLD_KEY)), isPlan)
266
267  if (projected.notices.length > 0) {
268    await $.store.set(PROJECT_TOLD_KEY, projected.told)
269  }
270
271  return [...judged.notices, ...weekly.notices, ...monthly.notices, ...projected.notices]
272}
273
274// The session files of the past: each session's stays on the disk, and one
275// untouched for a month is no longer read. `/tokens tidy` says how many there
276// are; with `now` it deletes them: only files of the sessions folder named as
277// a session's id, never this session's own.
278export const SESSION_FILE = /^[A-Za-z0-9_-]+\.json$/
279
280export async function tidy($: EngineInterface, isNow: boolean) {
281  const own = await ownFile($)
282
283  if (own === null) {
284    return 'Nothing to tidy: no home folder.'
285  }
286
287  const at = await $.clock.now()
288  let stale: { name: string; size: number }[] = []
289
290  try {
291    stale = (await $.fs.list(own.dir)).filter(
292      entry =>
293        entry.kind === 'file' &&
294        SESSION_FILE.test(entry.name) &&
295        entry.name !== own.name &&
296        entry.mtimeMs < at - STALE_MS,
297    )
298  } catch {
299    // No folder: nothing kept yet.
300  }
301
302  if (stale.length === 0) {
303    return 'Nothing to tidy: no session file is over 31 days old.'
304  }
305
306  const kb = Math.max(1, Math.round(stale.reduce((sum, entry) => sum + (entry.size ?? 0), 0) / 1024))
307  const files = `${stale.length} session ${stale.length === 1 ? 'file' : 'files'}`
308
309  if (!isNow) {
310    return `${files} untouched for over 31 days (${kb} KB) are no longer read. /tokens tidy now deletes them.`
311  }
312
313  const paths = stale.map(({ name }) => `${own.dir}/${name}`)
314  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
315  const argv = isWindows
316    ? ['powershell', '-NoProfile', '-Command', `Remove-Item -LiteralPath ${paths.map(path => `'${path.replaceAll("'", "''")}'`).join(',')}`]
317    : ['rm', '-f', '--', ...paths]
318
319  try {
320    const done = await $.process.run(argv, { timeoutMs: NOTIFY_MS })
321
322    return done.exitCode === 0
323      ? `Deleted ${stale.length} old session ${stale.length === 1 ? 'file' : 'files'} (${kb} KB).`
324      : `Tidy failed (exit ${done.exitCode}): ${done.stderr.trim().slice(0, 120)}`
325  } catch (error) {
326    return `Tidy failed: ${error instanceof Error ? error.message : String(error)}`
327  }
328}
329
330// Every session's counts, kept beside the mod's other files: the days as
331// CSV, and everything as JSON. Says where, or why not.
332export async function exported($: EngineInterface) {
333  const own = await ownFile($)
334
335  if (own === null) {
336    return 'Nothing to export to: no home folder.'
337  }
338
339  const at = await $.clock.now()
340  const sum = await gather($, at)
341  const folder = own.dir.slice(0, -'/sessions'.length)
342  const name = `${folder}/export-${dayOf(at)}`
343
344  try {
345    await $.fs.write(`${name}.csv`, exportCsv(sum))
346    await $.fs.write(`${name}.json`, JSON.stringify(sum, null, 2))
347  } catch (error) {
348    return `Export failed: ${error instanceof Error ? error.message : String(error)}`
349  }
350
351  return `Exported the last 31 days to ${name}.csv and ${name}.json.`
352}
353
354// A subagent's agent type, read from the session's agents by its id, and
355// whether this is the first of its turns counted. One the engine has
356// already let go of, or cannot list, is `subagent`.
357export async function agentOf($: EngineInterface, id: string, seen: Map<string, string>) {
358  const held = seen.get(id)
359
360  if (held !== undefined) {
361    return { type: held, isNew: false }
362  }
363
364  let type = 'subagent'
365
366  try {
367    type = (await $.agent.list()).find(agent => agent.id === id)?.type ?? type
368  } catch {
369    // Nothing to do: it is counted under the plain name.
370  }
371
372  seen.set(id, type)
373
374  return { type, isNew: true }
375}
376
hooks/measure.ts 142 lines
1// A measurement taken in: read with care, judged against the one before; and
2// what the band says when a reading or a hook went wrong.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface } from 'claude-code'
6import type { Snapshot } from '../types'
7import { review } from './alerts'
8import { spentSince, usd } from './cost'
9import { failedLine, readReading, record, resets, unreadLine } from './usage'
10import type { Reading } from './usage'
11import { countContext, pay, recount } from './ledger'
12import { RESET_MS, tick } from './state'
13
14// Not `snapshot`: a session reloaded from 0.1.0 still holds that key in its old shape.
15const snapshot = atom({ plugin: 'token-usage', key: 'usage' } as const, null)
16
17// What the person was told already, so a notice is not raised twice.
18const alerted = atom({ plugin: 'token-usage', key: 'alerted' } as const, {})
19
20// The windows that have just reset, while their reset plays.
21const fresh = atom({ plugin: 'token-usage', key: 'fresh' } as const, {})
22
23// Whether the last turn's tokens were all read.
24const turnRead = atom({ plugin: 'token-usage', key: 'turnRead' } as const, true)
25
26// The last hook of this mod that failed, and when.
27const failed = atom({ plugin: 'token-usage', key: 'failed' } as const, null)
28
29// A part the reading could not read keeps what was shown before.
30export const toSnapshot = (held: Snapshot | null, at: number, reading: Reading): Snapshot => ({
31  at,
32  tokens: reading.context?.tokens ?? held?.tokens ?? null,
33  window: reading.context?.window ?? held?.window ?? 0,
34  percent: reading.context === null ? (held?.percent ?? null) : reading.context.percent,
35  // A measurement with no reading of the windows says nothing of them: the
36  // windows held stand until their reset passes.
37  limits:
38    reading.limits === null || reading.limits.length === 0
39      ? (held?.limits ?? []).filter(
40          limit => limit.resetsAt === null || Date.parse(limit.resetsAt) > at,
41        )
42      : reading.limits.map(({ kind, percentUsed, resetsAt }) => ({
43          kind,
44          percentUsed,
45          resetsAt,
46          samples: record(
47            held?.limits.find(limit => limit.kind === kind)?.samples ?? [],
48            { at, percent: percentUsed },
49          ),
50        })),
51  usd: reading.unread.includes('cost') ? (held?.usd ?? null) : reading.usd,
52  unread: reading.unread,
53})
54
55// The engine skips a hook that fails and goes on beneath it, leaving no
56// trace: this notes it, so the band can say so.
57export async function note($: EngineInterface, event: string, error: { kind: string; message?: string }) {
58  try {
59    const at = await $.clock.now()
60
61    await update($, failed, () => ({ event, message: error.message ?? error.kind, at }))
62  } catch {
63    // Nothing to do: the engine reports the failure by name all the same.
64  }
65}
66
67// What the band says of what went wrong, or nothing: the parts a reading
68// could not read, and a hook that failed in the last ten minutes.
69export async function trouble($: EngineInterface, at: number) {
70  const usage = await read($, snapshot)
71  const unread = [...(usage?.unread ?? []), ...((await read($, turnRead)) ? [] : ['turn usage'])]
72
73  return [unreadLine(unread), failedLine(await read($, failed), at)].filter(line => line !== null)
74}
75
76// `budget` is null where the caller will not raise the notices: the budget is
77// then not judged, so its news is still news to the next caller.
78export async function measure(
79  $: EngineInterface,
80  context: unknown,
81  rateLimits: unknown,
82  cost: unknown,
83  hasEarlyAlert: boolean,
84  budget: number | null,
85  weekBudget: number | null = null,
86  projectBudgets: Readonly<Record<string, number>> = {},
87  monthBudget: number | null = null,
88) {
89  const reading = readReading(context, rateLimits, cost)
90  const at = await $.clock.now()
91  const told = await read($, alerted)
92  let judged: ReturnType<typeof review> = { notices: [], memory: told }
93  let renewed: ReturnType<typeof resets> = []
94  let spent = 0
95
96  // Judged against the very reading it replaces, so two measurements close
97  // together cannot both call the same climb news.
98  await update($, snapshot, held => {
99    const after = toSnapshot(held ?? null, at, reading)
100
101    judged = review(held ?? null, after, told, at, hasEarlyAlert)
102    renewed = resets(held ?? null, after)
103    spent = spentSince(held ?? null, after.usd)
104
105    return after
106  })
107  await update($, alerted, () => judged.memory)
108
109  if (renewed.length > 0) {
110    await update($, fresh, held => ({
111      ...held,
112      ...Object.fromEntries(renewed.map(({ kind, from }) => [kind, { at, from }])),
113    }))
114    // The reset plays once: when it is over the band is drawn at rest again.
115    $.clock.after(RESET_MS, () => void tick($))
116  }
117
118  return spent > 0 ? [...judged.notices, ...(await pay($, at, spent, budget, weekBudget, projectBudgets, monthBudget))] : judged.notices
119}
120
121// A compaction is no turn, so no measurement follows it and the band would
122// hold the size from before until the next turn. The size it left goes in
123// now: the count the compaction gives, else the free local count of the
124// window. Only the context moves; the cost and the windows wait for the
125// next measurement, so what the compaction cost is still paid there.
126export async function compacted($: EngineInterface, tokensAfter: number | undefined) {
127  const usage =
128    tokensAfter === undefined ? await countContext($, 'summary') : ((await recount($)) ?? (await $.session.usage()))
129  const window = usage?.context.window ?? 0
130  const tokens = tokensAfter ?? usage?.context.breakdown?.totalTokens
131
132  if (usage === null || tokens === undefined || window <= 0) {
133    return
134  }
135
136  const at = await $.clock.now()
137
138  await update($, snapshot, held =>
139    held === null ? held : { ...held, at, tokens, window, percent: Math.min(100, Math.round((tokens / window) * 100)) },
140  )
141}
142
hooks/notices.ts 532 lines
1// Notices: the crab in the band, toasts and the notification outside the
2// app; and the /tokens words that change the style.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Timer } from 'claude-code'
6import type { Notice, Saying, Style } from '../types'
7import { loudest } from './alerts'
8import { spoken, toastArgv, toastEnv } from './notify'
9import { apply } from './style'
10import { CACHE_WARN_MS, WARM_TEXT, cacheNotice, cacheRates, isHeavyPing, isWarmPing, learnedTtl } from './cache'
11import { shortTokens } from './cost'
12import { isQuiet } from './usage'
13import { AGENTS_PANE, CACHE_TICK_MS, CHOICE_MS, FORK_SHORT_KEY, LEAVE_MS, NOTIFY_MS, SAY_MS, STYLE_KEY, SWITCH_FILE, scope, styleOf, tick } from './state'
14import { stoppedAdvice } from './advice'
15import type { ChoiceKey } from './advice'
16
17const now = atom({ plugin: 'token-usage', key: 'now' } as const, 0)
18
19// The style `/tokens` chose; null while the options' own stand.
20const chosen = atom({ plugin: 'token-usage', key: 'style' } as const, null)
21
22// The notice the crab is bringing; null once it has left or was sent away.
23const saying = atom({ plugin: 'token-usage', key: 'saying' } as const, null)
24
25// A notice outside the app. A desktop session cannot start a program from
26// here: the notice goes to the notification hooks, where the mod's own
27// command hook raises it in Windows with the crab. A terminal on Windows
28// raises it through PowerShell; elsewhere the person's own channel takes it.
29// Says how it went.
30export async function raise($: EngineInterface, notice: Notice, surface: string | undefined) {
31  if (surface === 'desktop') {
32    const sent = await $.ui.notify(notice.text === '' ? notice.title : notice.text, {
33      title: notice.title,
34    })
35
36    return sent.isSent ? 'sent' : 'handed to the notification hooks'
37  }
38
39  if ((await $.env.get('OS')) !== 'Windows_NT') {
40    const sent = await $.ui.notify(spoken(notice), { title: 'token-usage' })
41
42    return sent.isSent ? 'sent' : `not sent (${sent.reason})`
43  }
44
45  const done = await $.process.run(toastArgv, {
46    env: toastEnv(notice),
47    timeoutMs: NOTIFY_MS,
48  })
49
50  return done.exitCode === 0 ? 'sent' : `not sent (PowerShell exit ${done.exitCode})`
51}
52
53// A notice to wherever the style sends notices. Says how the one outside
54// the app went, or nothing when none was sent there. Where the crab brings
55// the notice in the app, the toast would say it twice.
56export async function announce(
57  $: EngineInterface,
58  style: Style,
59  notice: Notice,
60  hasCrab: boolean,
61  surface: string | undefined,
62) {
63  if (style.notify !== 'windows' && !hasCrab) {
64    $.ui.toast(notice.line)
65  }
66
67  if (style.notify === 'app') {
68    return undefined
69  }
70
71  // Quiet hours keep a notice in the app.
72  if (isQuiet(style.quiet, await $.clock.now())) {
73    return 'held for quiet hours'
74  }
75
76  try {
77    return await raise($, notice, surface)
78  } catch (error) {
79    return `not sent (${error instanceof Error ? error.message : String(error)})`
80  }
81}
82
83// What `/tokens alerts test` raises: a notice as a real one reads.
84export const SAMPLE: Notice = {
85  kind: 'near',
86  title: 'Test alert: 5-hour window at 86%',
87  text: 'Resets in 2h14m. Nothing is wrong: this is a test.',
88  line: 'Test alert: 5-hour window at 86%, resets in 2h14m',
89  bar: { name: '5-hour window', value: 0.86, label: '86%', status: 'resets in 2h14m' },
90  window: 'five_hour',
91}
92
93// What `/tokens alerts test week` raises: the same for the weekly window.
94export const SAMPLE_WEEK: Notice = {
95  kind: 'near',
96  title: 'Test alert: Weekly window at 86%',
97  text: 'Resets in 1d 8h. Nothing is wrong: this is a test.',
98  line: 'Test alert: Weekly window at 86%, resets in 1d 8h',
99  bar: { name: 'Weekly window', value: 0.86, label: '86%', status: 'resets in 1d 8h' },
100  window: 'seven_day',
101}
102
103// The main loop's running turn, a compaction asked for when it ends, and
104// how far the heavy-turn line was raised for the session.
105const turnNow = atom({ plugin: 'token-usage', key: 'turnNow' } as const, null)
106
107const compactAfter = atom({ plugin: 'token-usage', key: 'compactAfter' } as const, false)
108
109const heavyBoost = atom({ plugin: 'token-usage', key: 'heavyBoost' } as const, 1)
110
111// The reading the band draws from: the context's fill for what to do after a stop.
112const usage = atom({ plugin: 'token-usage', key: 'usage' } as const, null)
113
114// The pause's own: the idle time and the context it began with.
115const idleSince = atom({ plugin: 'token-usage', key: 'idleSince' } as const, null)
116
117const cacheModel = atom({ plugin: 'token-usage', key: 'cacheModel' } as const, null)
118
119const forkShort = atom({ plugin: 'token-usage', key: 'forkShort' } as const, false)
120
121const pauses = atom({ plugin: 'token-usage', key: 'pauses' } as const, [])
122
123// The pause the cache counts down: the band's redraws, and its one warning.
124const pausing: { ticking?: Timer; warning?: Timer } = {}
125
126export function endPause() {
127  pausing.ticking?.cancel()
128  pausing.warning?.cancel()
129  pausing.ticking = undefined
130  pausing.warning = undefined
131}
132
133// A pause begins: at a turn's end, at a quiet Keep warm ping, or long since,
134// on a resumed session. The band counts the cache down, more often near its
135// end, until it has lapsed; the crab warns once, two minutes before, of a
136// cache that lasts longer than that.
137export async function beginPause(
138  $: EngineInterface,
139  base: Style,
140  at: number,
141  tokens: number | null,
142  extra: { afterFork?: boolean; usd?: number; lapsed?: boolean } = {},
143) {
144  await update($, idleSince, () => ({ at, tokens, ...extra }))
145  endPause()
146
147  const ttl = learnedTtl(await read($, pauses)).ttl
148
149  if (ttl === null) {
150    return
151  }
152
153  const left = at + ttl - (await $.clock.now())
154  const ticker = $.clock.every(CACHE_TICK_MS, () => void tick($))
155
156  pausing.ticking = ticker
157  $.clock.after(Math.max(0, left) + CACHE_TICK_MS * 4, () => ticker.cancel())
158
159  if (ttl > CACHE_WARN_MS && left > CACHE_WARN_MS) {
160    pausing.warning = $.clock.after(left - CACHE_WARN_MS, () => void warnCache($, base, at, ttl))
161  }
162}
163
164// Keep warm reads the cache with a fork of the main thread's last request:
165// no turn, nothing in the conversation. A busy API is tried once more, half a
166// minute on, while the cache still has the time; one ping is out at a time.
167const PING = 'Reply with the single word: warm'
168const RETRY_MS = 30_000
169const pinging = { isOut: false }
170
171export async function keepWarm($: EngineInterface, isRetry = false) {
172  const held = await read($, idleSince)
173
174  if (pinging.isOut) {
175    return
176  }
177
178  // A turn runs, or none has ended yet: there is no pause to keep warm.
179  if (held === null) {
180    $.ui.toast('Nothing to keep warm now: Keep warm works in a pause after a reply.')
181
182    return
183  }
184
185  pinging.isOut = true
186
187  const reply = await $.model.fork({ prompt: PING }).catch(() => null)
188  const at = await $.clock.now()
189
190  pinging.isOut = false
191
192  // A turn began meanwhile: the cache is that turn's now. A read hands back a
193  // copy, so the pause is told by when it began.
194  if ((await read($, idleSince))?.at !== held.at) {
195    return
196  }
197
198  if (reply === null) {
199    $.ui.toast('Keep warm failed: the engine did not send the ping.')
200
201    return
202  }
203
204  if (!reply.isAnswered) {
205    const ttl = learnedTtl(await read($, pauses)).ttl
206    const isBusy = reply.reason === 'api-error' && (reply.status === null || reply.status === 429 || reply.status >= 500)
207
208    if (isBusy && !isRetry && ttl !== null && at + RETRY_MS < held.at + ttl) {
209      $.ui.toast('The API is busy: Keep warm tries again in 30 seconds.')
210      $.clock.after(RETRY_MS, () => void keepWarm($, true))
211
212      return
213    }
214
215    $.ui.toast(
216      reply.reason === 'nothing-to-fork'
217        ? 'Nothing to keep warm yet: this conversation has no reply.'
218        : reply.reason === 'api-error'
219          ? `Keep warm failed: the API call failed${reply.status === null ? '' : ` (${reply.status})`}.`
220          : reply.reason === 'aborted'
221            ? 'Keep warm was interrupted.'
222            : 'Keep warm failed: the ping came back empty.',
223    )
224
225    return
226  }
227
228  const read_ = reply.usage.cache_read_input_tokens
229  const written = reply.usage.cache_creation_input_tokens
230
231  if (!isWarmPing(read_)) {
232    $.ui.toast(`The cache had already lapsed: the ping wrote ${shortTokens(written)}.`)
233
234    return
235  }
236
237  const rates = cacheRates(await read($, cacheModel), learnedTtl(await read($, pauses)).ttl)
238  // A first ping writes a block of its own, once: said, so its cost is seen.
239  const besides = isHeavyPing(read_, written) ? `, wrote ${shortTokens(written)} once (about ${shortTokens(written * rates.write)})` : ''
240
241  $.ui.toast(`Cache kept warm: read ${shortTokens(read_)} (about ${shortTokens(read_ * rates.read)} at full price)${besides}.`)
242  // The cache's time starts over from the ping; the pause after it is told
243  // apart, so it never teaches how long the cache lasts.
244  await beginPause($, scope.base, at, held.tokens, { afterFork: true })
245}
246
247// A pause begun at a ping lapsed before the cache's learned time: the ping
248// did not hold it. Keep warm sends a turn that shows from now on.
249export async function forkFellShort($: EngineInterface) {
250  if (await read($, forkShort)) {
251    return
252  }
253
254  await update($, forkShort, () => true)
255  await $.store.set(FORK_SHORT_KEY, true).catch(() => undefined)
256  $.ui.toast("Keep warm's quiet ping did not hold the cache: from now on it sends a short turn, which shows in the conversation.")
257}
258
259// What the cache's words are priced by: the model's read, the learned time's
260// write, a resumed session's own dollars, and how Keep warm goes.
261export async function cacheSaid($: EngineInterface, ttl: number | null, usd: number | null = null) {
262  return { rates: cacheRates(await read($, cacheModel), ttl), usd, isWarmShown: await read($, forkShort) }
263}
264
265// Until when `/tokens alerts test lapsed` has the band show a lapsed cache.
266const cacheTest = atom({ plugin: 'token-usage', key: 'cacheTest' } as const, 0)
267
268const TEST_LAPSE_MS = 60_000
269const FALLBACK_TTL = 5 * 60_000
270
271// `/tokens alerts test cache`: the warning as it comes two minutes before a
272// lapse, for the context as it is now, its buttons standing as long.
273export async function testCache($: EngineInterface, base: Style, ttl: number | null) {
274  const style = await styleOf($, base)
275  const notice = cacheNotice(CACHE_WARN_MS, ttl ?? FALLBACK_TTL, (await read($, usage))?.tokens ?? null, await cacheSaid($, ttl))
276
277  if (style.hidden.includes('crab') || style.hidden.includes('alerts')) {
278    $.ui.toast(notice.line)
279  } else {
280    await say($, notice, CACHE_WARN_MS)
281  }
282}
283
284// `/tokens alerts test lapsed`: the band shows a lapsed cache for a minute.
285export async function testLapse($: EngineInterface) {
286  const until = (await $.clock.now()) + TEST_LAPSE_MS
287
288  await update($, cacheTest, () => until)
289  await tick($)
290  $.clock.after(TEST_LAPSE_MS, () => void tick($))
291}
292
293// Once a pause, two minutes before the cache lapses, the crab says so in the
294// app alone: a reply now keeps the context cached, which only someone at the
295// app can give. Where the pause has ended since, or alerts are off, nothing.
296export async function warnCache($: EngineInterface, base: Style, began: number, ttl: number) {
297  const held = await read($, idleSince)
298  const style = await styleOf($, base)
299
300  if (held === null || held.at !== began || style.hidden.includes('alerts')) {
301    return
302  }
303
304  const left = Math.max(0, began + ttl - (await $.clock.now()))
305  const notice = cacheNotice(left, ttl, held.tokens, await cacheSaid($, ttl, held.usd ?? null))
306
307  // Its choices stand until the cache lapses, when they no longer help.
308  if (style.hidden.includes('crab')) {
309    $.ui.toast(notice.line)
310  } else {
311    await say($, notice, Math.max(SAY_MS, left))
312  }
313}
314
315// The crab comes with a notice; the clock has it walk off, and then go.
316export async function say($: EngineInterface, notice: Notice, stay = SAY_MS) {
317  const at = await $.clock.now()
318
319  await update($, saying, () => ({ notice, at, stay }))
320  $.clock.after(stay, () => void tick($))
321  $.clock.after(stay + LEAVE_MS, () => void tick($))
322}
323
324// Sent away: its time counts as up from now, so it walks off before it goes;
325// a question's longer time as much as a word's.
326export async function hush($: EngineInterface) {
327  const at = await $.clock.now()
328
329  await update($, saying, held => (held === null ? null : { ...held, at: at - (held.stay ?? SAY_MS) }))
330  await update($, now, () => at)
331  $.clock.after(LEAVE_MS, () => void tick($))
332}
333
334// The notice the crab is bringing at a moment, and whether it is still
335// standing with it (`in`) or walking off (`out`); null when it has gone.
336export const heard = (held: Saying | null, style: Style, at: number) => {
337  if (held === null || style.hidden.includes('crab') || style.hidden.includes('alerts')) {
338    return null
339  }
340
341  const age = at - held.at
342  const stay = held.stay ?? SAY_MS
343
344  if (age >= stay + LEAVE_MS) {
345    return null
346  }
347
348  return { notice: held.notice, phase: age < stay ? ('in' as const) : ('out' as const) }
349}
350
351// The script that notifies outside the app cannot see the mod's state: it
352// reads this file. Where the file cannot be written the script stays on.
353export async function tell($: EngineInterface, style: Style) {
354  try {
355    const home = await $.env.get('USERPROFILE')
356
357    if (typeof home === 'string' && home !== '') {
358      await $.fs.write(
359        `${home}/${SWITCH_FILE}`,
360        JSON.stringify({
361          isOn: !style.hidden.includes('crab') && !style.hidden.includes('alerts'),
362          isPrivate: style.isPrivate,
363          quiet: style.quiet,
364          hasPopup: style.hasPopup,
365        }),
366      )
367    }
368  } catch {
369    // Nothing to do: the script's own default is on.
370  }
371}
372
373// A notice goes out: the crab brings the gravest where it is switched on,
374// and on the desktop it stands in for the toast. Says how the last notice
375// outside the app went, or nothing when none was sent there.
376export async function bring(
377  $: EngineInterface,
378  style: Style,
379  notices: readonly Notice[],
380  surface: string | undefined,
381) {
382  const hasCrab = !style.hidden.includes('crab')
383  const first = loudest(notices)
384  let raised: string | undefined
385
386  if (hasCrab && first !== null) {
387    await say($, first, first.choices === undefined ? SAY_MS : CHOICE_MS)
388  }
389
390  for (const notice of notices) {
391    raised =
392      (await announce($, style, notice, hasCrab && surface === 'desktop', surface)) ?? raised
393  }
394
395  return raised
396}
397
398// The band's Compact button. The engine refuses while a turn runs, so the
399// button says so without asking. A headless session (the desktop app runs
400// one) cannot compact from a hook: there the button runs the person's own
401// `/compact`, which the session takes as a turn of its own. Any other
402// refusal, and a hook's veto, is said as the engine gave it.
403export async function compact($: EngineInterface, isWorking: boolean) {
404  if (isWorking) {
405    $.ui.toast('Claude is still working: press Compact again when the turn ends.')
406
407    return
408  }
409
410  try {
411    const done = await $.session.compact({})
412
413    if ('skip' in done && typeof done.skip === 'string') {
414      $.ui.toast(`Compact skipped: ${done.skip}`)
415    }
416  } catch {
417    try {
418      await $.command.run({ command: 'compact', args: '' })
419    } catch (error) {
420      $.ui.toast(`Compact did not run: ${error instanceof Error ? error.message : String(error)}`)
421    }
422  }
423}
424
425// One path for a typed word and a pressed button: the state redraws the band
426// now, the store keeps the choice for the next session.
427export async function choose($: EngineInterface, base: Style, args: string) {
428  // `here` in a project's budget is the folder the session runs in.
429  const outcome = apply(await styleOf($, base), args.replace(/^(\s*budget\s+project\s+)here\b/i, `$1${scope.project}`))
430
431  if ('reset' in outcome) {
432    await update($, chosen, () => null)
433    await $.store.delete(STYLE_KEY)
434    await tell($, base)
435  } else if ('style' in outcome) {
436    const { style } = outcome
437
438    await update($, chosen, () => style)
439    await $.store.set(STYLE_KEY, style)
440    await tell($, style)
441  }
442
443  return outcome
444}
445
446// What a choice the crab offers does. The crab goes first, whatever it was;
447// a stop ends the running turn at once and asks what next.
448export async function act($: EngineInterface, key: ChoiceKey, tokens: string) {
449  await hush($)
450
451  switch (key) {
452    case 'stop': {
453      const turnId = await read($, turnNow)
454
455      if (turnId !== null) {
456        try {
457          await $.turn.abort({ turnId })
458        } catch {
459          // It had ended already: what next is still the question.
460        }
461      }
462
463      const next = stoppedAdvice((await read($, usage))?.percent ?? null, tokens)
464
465      await say(
466        $,
467        {
468          kind: 'half',
469          title: next.title,
470          text: next.text,
471          line: `${next.title} ${next.text}`,
472          bar: { name: 'This turn', value: 1, label: '', status: '' },
473          choices: next.choices,
474        },
475        CHOICE_MS,
476      )
477
478      return
479    }
480    case 'compactAfter':
481      await update($, compactAfter, () => true)
482      $.ui.toast('Compacting when this turn ends.')
483
484      return
485    case 'raise':
486      await update($, heavyBoost, held => held * 2)
487      $.ui.toast('The heavy-turn line is twice as high for this session.')
488
489      return
490    case 'agents':
491      await $.ui.open({ id: AGENTS_PANE, title: 'Token usage · agents' })
492
493      return
494    case 'compact':
495      // A compaction outlasts a press's budget: it runs once the press has answered.
496      $.clock.after(COMPACT_SOON_MS, () => void compact($, false))
497
498      return
499    case 'fresh':
500      await $.command.run({ command: 'clear' })
501
502      return
503    case 'continue':
504      await $.prompt.submit({ text: 'Continue where you stopped.', asUser: true })
505
506      return
507    case 'warm':
508      // Quietly, where a ping has been seen to hold the cache; else with a
509      // turn that shows.
510      if (await read($, forkShort)) {
511        await $.prompt.submit({ text: WARM_TEXT, asUser: true })
512      } else {
513        await keepWarm($)
514      }
515
516      return
517    default:
518      return
519  }
520}
521
522// How soon after a press its compaction starts.
523const COMPACT_SOON_MS = 100
524
525// A compaction asked for while the turn ran, once the turn has ended.
526export async function compactIfAsked($: EngineInterface) {
527  if (await read($, compactAfter)) {
528    await update($, compactAfter, () => false)
529    await compact($, false)
530  }
531}
532
hooks/pane-agents.tsx 202 lines
1// The /tokens agents pane: the subagents of the turn, a card each, with a
2// crab dressed for its job at work, done, asleep or failing.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, MatchedHook, Timer } from 'claude-code'
6import { JOB_COLORS, jobCrabSvg, jobOf } from './crab'
7import { shortTokens } from './cost'
8import { countOf, isBusy, lineUp, meterSvg, phaseOf, settled, toolLine, workedFor } from './crew'
9import { paletteFor } from './style'
10import { eighths } from './usage'
11import { CREW_TICK_MS, scope, styleOf, tick } from './state'
12
13// The subagents of the turn, by id.
14const crew = atom({ plugin: 'token-usage', key: 'crew' } as const, {})
15
16// The terminal's bar of a subagent's share, in cells.
17const TERMINAL_CELLS = 10
18const METER_PX = 260
19
20// How each stands by the engine's own list; says whether any still runs.
21export async function refreshCrew($: EngineInterface) {
22  try {
23    const listed = await $.agent.list()
24    const at = await $.clock.now()
25    let isRunning = false
26
27    await update($, crew, held => {
28      const next = settled(held, listed, at)
29
30      isRunning = isBusy(next)
31
32      return next
33    })
34    await tick($)
35
36    return isRunning
37  } catch {
38    return false
39  }
40}
41
42// Reads the crew's list every few seconds while any of it runs: from a spawn,
43// a subagent's tool call, or the pane opening on one at work. One watch at a
44// time, its clock held in `watch`, which the module's own load makes: a
45// reload stops the clock and starts the holder over with it, so no watch is
46// left thought running with no clock behind it. It ends when nothing runs.
47export function watchCrew($: EngineInterface, watch: { ticker?: Timer }) {
48  if (watch.ticker !== undefined) {
49    return
50  }
51
52  const ticker = $.clock.every(CREW_TICK_MS, () => {
53    void refreshCrew($).then(isRunning => {
54      if (!isRunning) {
55        ticker.cancel()
56        watch.ticker = undefined
57      }
58    })
59  })
60
61  watch.ticker = ticker
62}
63
64type DrawAgentsPaneHook = MatchedHook<'ui.render', { component: 'Pane'; requestId: string }>
65
66export async function drawAgentsPane($: Parameters<DrawAgentsPaneHook>[0], e: Parameters<DrawAgentsPaneHook>[1]) {
67  const { Box, Text } = $.ui.resolve(e)
68  const at = await $.clock.now()
69  const mates = lineUp(await read($, crew))
70
71  if (mates.length === 0) {
72    return (
73      <Text dimColor wrap="wrap">
74        No subagent has run yet in this session. When Claude starts one (Explore, Plan, ...), its card shows here, and the last turn that had some stays.
75      </Text>
76    )
77  }
78
79  const most = Math.max(1, ...mates.map(countOf))
80  const running = mates.filter(mate => phaseOf(mate, at) === 'work').length
81  const total = mates.reduce((sum, mate) => sum + countOf(mate), 0)
82  const head = (
83    <Box justifyContent="space-between" gap={1}>
84      <Text weight="semibold">Subagents · this turn</Text>
85      <Text dimColor>{`${running} running · ${shortTokens(total)}`}</Text>
86    </Box>
87  )
88  const says = (mate: (typeof mates)[number]) => {
89    const phase = phaseOf(mate, at)
90    const tools = toolLine(mate)
91
92    return [tools === '' ? '…' : tools, workedFor(mate, at), ...(phase === 'fail' ? [mate.status] : [])].join(' · ')
93  }
94  // The context its last response read, which is what Claude Code counts as
95  // its tokens; beside its figure, where a long tool line cannot cut it off.
96  const contextOf = (mate: (typeof mates)[number], word: string) =>
97    (mate.context ?? null) === null ? null : `${word} ${shortTokens(mate.context ?? 0)}`
98
99  // The terminal draws no picture: a mark of how it stands, its job's colour
100  // and a bar of eighths.
101  if (e.surface === 'terminal') {
102    return (
103      <Box flexDirection="column">
104        {head}
105        {mates.map(mate => {
106          const phase = phaseOf(mate, at)
107          const color = JOB_COLORS[jobOf(mate.type)]
108
109          return (
110            <Box flexDirection="column">
111              <Box gap={1}>
112                <Text color={phase === 'fail' ? 'error' : phase === 'work' ? 'success' : 'inactive'}>
113                  {phase === 'fail' ? '✕' : phase === 'work' ? '●' : '✓'}
114                </Text>
115                <Box flexShrink={1} minWidth={0}>
116                  <Text color={color} wrap="truncate-end">
117                    {mate.type}
118                  </Text>
119                </Box>
120                <Box flexShrink={0}>
121                  {eighths((countOf(mate) / most) * 100, null, TERMINAL_CELLS).map(({ text }) => (
122                    <Text color={color} backgroundColor="subtle">
123                      {text}
124                    </Text>
125                  ))}
126                </Box>
127                <Text weight="semibold">{shortTokens(countOf(mate))}</Text>
128                {contextOf(mate, 'ctx') === null ? null : (
129                  <Box flexShrink={1} minWidth={0}>
130                    <Text dimColor wrap="truncate-end">
131                      {contextOf(mate, 'ctx')}
132                    </Text>
133                  </Box>
134                )}
135              </Box>
136              <Box paddingLeft={2}>
137                <Text dimColor wrap="truncate-end">
138                  {[mate.description, says(mate)].filter(part => part !== '').join(' · ')}
139                </Text>
140              </Box>
141            </Box>
142          )
143        })}
144      </Box>
145    )
146  }
147
148  const table = $.ui.resolve(e)
149
150  if (!('Svg' in table)) {
151    return <Text dimColor>The agents pane draws on the desktop and in a terminal.</Text>
152  }
153
154  const { Svg } = table
155  const palette = paletteFor(await styleOf($, scope.base))
156  const wide = Math.max(120, Math.min(METER_PX, Math.round(e.props.bodyColumns * 9.3) - 160))
157
158  return (
159    <Box flexDirection="column" gap={1}>
160      {head}
161      {mates.map(mate => {
162        const phase = phaseOf(mate, at)
163        const job = jobOf(mate.type)
164        const color = JOB_COLORS[job]
165        const crab = jobCrabSvg(job, phase, `${mate.type} crab, ${phase === 'work' ? 'at work' : phase}`, palette)
166        const meter = meterSvg(countOf(mate) / most, phase === 'fail' ? palette.error : color, wide, `${shortTokens(countOf(mate))} tokens`)
167
168        return (
169          <Box gap={1} alignItems="center">
170            <Box width={6} flexShrink={0} justifyContent="center">
171              <Svg source={crab.source} alt={crab.alt} width={crab.width} height={crab.height} />
172            </Box>
173            <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
174              <Box gap={1}>
175                <Box flexShrink={0}>
176                  <Text weight="semibold" color={color}>
177                    {mate.type}
178                  </Text>
179                </Box>
180                <Box flexShrink={1} minWidth={0}>
181                  <Text wrap="truncate-end" {...(phase === 'work' ? {} : { dimColor: true })}>
182                    {mate.description}
183                  </Text>
184                </Box>
185              </Box>
186              <Text wrap="truncate-end" {...(phase === 'fail' ? { color: 'error' } : { dimColor: true })}>
187                {says(mate)}
188              </Text>
189              <Svg source={meter.source} alt={meter.alt} width={wide} height={4} />
190            </Box>
191            <Box flexDirection="column" flexShrink={0} alignItems="flex-end">
192              <Text weight="semibold">{shortTokens(countOf(mate))}</Text>
193              <Text dimColor>processed</Text>
194              {contextOf(mate, 'context') === null ? null : <Text dimColor>{contextOf(mate, 'context')}</Text>}
195            </Box>
196          </Box>
197        )
198      })}
199    </Box>
200  )
201}
202
hooks/pane-context.tsx 204 lines
1// The /tokens context pane: what fills the context, as /context breaks it down.
2
3import { atom, read } from 'claude-code'
4import type { MatchedHook, RenderChildren } from 'claude-code'
5import { counted, dayOf, shortTokens, total } from './cost'
6import { appOf, glyphOf, kindsOf, servers, shortPath, trendLine } from './context'
7import { tone } from './usage'
8import { countContext } from './ledger'
9import { BESIDE_COLUMNS, SQUARE_CELLS, saneBreakdown } from './state'
10
11// What fills the context, as /context breaks it down.
12const breakdown = atom({ plugin: 'token-usage', key: 'breakdown' } as const, null)
13
14// The set-up of each day as last counted, for every session: the latest wins.
15const setUps = atom({ plugin: 'token-usage', key: 'setUps' } as const, {})
16
17type DrawContextPaneHook = MatchedHook<'ui.render', { component: 'Pane'; requestId: string }>
18
19export async function drawContextPane($: Parameters<DrawContextPaneHook>[0], e: Parameters<DrawContextPaneHook>[1]) {
20    const { Box, Text, Button } = $.ui.resolve(e)
21    const held = await read($, breakdown)
22    const value = held === null ? null : saneBreakdown(held.value)
23
24    if (held === null || value === null) {
25      return <Text dimColor>Nothing counted yet: run /tokens context.</Text>
26    }
27
28    const kinds = kindsOf(value.categories)
29    const kindOf = new Map(value.categories.map(({ name, kind }) => [name, kind]))
30    const trend = trendLine(await read($, setUps), dayOf(held.at))
31    const home = await $.env.get('USERPROFILE').catch(() => undefined) ?? (await $.env.get('HOME').catch(() => undefined))
32    const row = (label: RenderChildren, figure: string) => (
33      <Box justifyContent="space-between" gap={1}>
34        <Box flexShrink={1} minWidth={0}>
35          {label}
36        </Box>
37        <Text>{figure}</Text>
38      </Box>
39    )
40    const share = (tokens: number) => `${Math.round((tokens / Math.max(1, value.rawMaxTokens)) * 100)}%`
41    const grid = (
42      <Box flexDirection="column" flexShrink={0}>
43        {value.gridRows.map(squares => (
44          <Box>
45            {squares.map(square => {
46              const glyph = glyphOf(square, kindOf)
47
48              // Each square in a cell of its own: the glyphs are not one width
49              // in a proportional face.
50              return (
51                <Box width={SQUARE_CELLS} flexShrink={0}>
52                  {glyph === '■' || glyph === '▪' ? <Text color={square.color}>{glyph}</Text> : <Text dimColor>{glyph}</Text>}
53                </Box>
54              )
55            })}
56          </Box>
57        ))}
58      </Box>
59    )
60    const legend = (
61      <Box flexDirection="column" flexGrow={1} minWidth={0}>
62        {kinds.used.map(({ name, tokens, color }) => (
63          <Box justifyContent="space-between" gap={1}>
64            <Box gap={1} flexShrink={1} minWidth={0}>
65              <Text color={color}>■</Text>
66              <Text wrap="truncate-end">{name}</Text>
67            </Box>
68            <Text dimColor>{`${shortTokens(tokens)} ${share(tokens)}`}</Text>
69          </Box>
70        ))}
71        <Box justifyContent="space-between" gap={1}>
72          <Text dimColor>▨ Autocompact buffer</Text>
73          <Text dimColor>{shortTokens(kinds.buffer)}</Text>
74        </Box>
75        <Box justifyContent="space-between" gap={1}>
76          <Text dimColor>□ Free space</Text>
77          <Text dimColor>{shortTokens(kinds.free)}</Text>
78        </Box>
79      </Box>
80    )
81    const isBeside = e.props.bodyColumns >= BESIDE_COLUMNS
82    // Only the servers with tools in the window are listed: the rest wait to
83    // be searched for, and are said in one line.
84    const mcp = servers(value.mcpTools)
85    const inWindow = mcp.filter(({ loaded }) => loaded > 0)
86    const waiting = mcp.length - inWindow.length
87    const files = [...value.memoryFiles].sort((a, b) => b.tokens - a.tokens)
88    const skills = value.skills
89    const agents = value.agents.reduce((sum, agent) => sum + agent.tokens, 0)
90
91    return (
92      <Box flexDirection="column">
93        <Box justifyContent="space-between" gap={1}>
94          <Box gap={1}>
95            <Text weight="semibold">Context</Text>
96            <Text dimColor>{`${shortTokens(value.totalTokens)} / ${shortTokens(value.rawMaxTokens)}`}</Text>
97          </Box>
98          <Text color={tone(value.percentage)} weight="semibold">{`${Math.round(value.percentage)}%`}</Text>
99        </Box>
100        {trend !== null && (
101          <Text dimColor wrap="wrap">
102            {trend}
103          </Text>
104        )}
105        <Text> </Text>
106        <Box flexDirection={isBeside ? 'row' : 'column'} gap={2}>
107          {grid}
108          {legend}
109        </Box>
110        {(inWindow.length > 0 || kinds.deferred > 0) && (
111          <Box flexDirection="column">
112            <Text> </Text>
113            <Text weight="semibold">MCP servers</Text>
114            {inWindow.map(({ server, tokens, loaded, total: all, deferred, tools: called }) => {
115              const { name, color } = appOf(server, called)
116
117              // The name gives way first; the count of tools and the tokens
118              // keep their room, on one line.
119              return (
120                <Box justifyContent="space-between" gap={1}>
121                  <Box gap={1} flexShrink={1} minWidth={0}>
122                    <Box flexShrink={1} minWidth={0}>
123                      {color === null ? (
124                        <Text weight="semibold" wrap="truncate-end">
125                          {name}
126                        </Text>
127                      ) : (
128                        <Text weight="semibold" color={color} wrap="truncate-end">
129                          {name}
130                        </Text>
131                      )}
132                    </Box>
133                    <Box flexShrink={0}>
134                      <Text dimColor>{`${loaded}/${all} tools`}</Text>
135                    </Box>
136                  </Box>
137                  <Box flexShrink={0}>
138                    {tokens > 0 ? (
139                      <Text>{shortTokens(tokens)}</Text>
140                    ) : (
141                      <Text dimColor>{deferred > 0 ? `${shortTokens(deferred)} deferred` : '–'}</Text>
142                    )}
143                  </Box>
144                </Box>
145              )
146            })}
147            {waiting > 0 || kinds.deferred > 0 ? (
148              <Text dimColor wrap="wrap">
149                {[
150                  waiting > 0 ? `+ ${waiting} ${waiting === 1 ? 'server' : 'servers'} not loaded` : '+',
151                  kinds.deferred > 0 ? `${shortTokens(kinds.deferred)} deferred, outside the window` : null,
152                ]
153                  .filter(part => part !== null)
154                  .join(waiting > 0 ? ' · ' : ' ')}
155              </Text>
156            ) : null}
157          </Box>
158        )}
159        {files.length > 0 && (
160          <Box flexDirection="column">
161            <Text> </Text>
162            <Text weight="semibold">Memory files</Text>
163            {files.map(({ path, type, tokens }) =>
164              row(
165                <Box gap={1} minWidth={0}>
166                  <Text wrap="truncate-start">{shortPath(path, typeof home === 'string' ? home : null)}</Text>
167                  <Text dimColor>{type}</Text>
168                </Box>,
169                shortTokens(tokens),
170              ),
171            )}
172          </Box>
173        )}
174        {skills !== undefined && (
175          <Box flexDirection="column">
176            <Text> </Text>
177            {row(<Text weight="semibold">Skills</Text>, shortTokens(skills.tokens))}
178            <Text dimColor wrap="wrap">
179              {`${skills.includedSkills} of ${skills.totalSkills} listed · most: ${[...skills.skillFrontmatter]
180                .sort((a, b) => b.tokens - a.tokens)
181                .slice(0, 3)
182                .map(({ name, tokens }) => `${name} ${shortTokens(tokens)}`)
183                .join(', ')}`}
184            </Text>
185          </Box>
186        )}
187        {value.agents.length > 0 && (
188          <Box flexDirection="column">
189            <Text> </Text>
190            {row(<Text weight="semibold">{`Custom agents · ${value.agents.length}`}</Text>, shortTokens(agents))}
191          </Box>
192        )}
193        <Text> </Text>
194        <Box gap={1} alignItems="center" flexWrap="wrap">
195          <Text dimColor>{held.detail === 'full' ? 'counted exactly' : 'estimated'}</Text>
196          {held.detail === 'summary' && (
197            <Button key="context-full" label="Count exactly" variant="secondary" onPress={() => void countContext($, 'full')} />
198          )}
199          <Button key="context-refresh" label="Refresh" variant="secondary" onPress={() => void countContext($, held.detail)} />
200        </Box>
201      </Box>
202    )
203}
204
hooks/crew.ts 151 lines
1// The subagents of a turn, the crew the agents pane draws: who each is, what
2// it has called and counted, and how it stands. Read field by field from
3// what the engine says at a spawn, a tool call, a turn and its agent list.
4
5import type { JobPhase } from './crab'
6
7export type Mate = {
8  id: string
9  type: string
10  description: string
11  status: string
12  tokens: number
13  // What its responses have counted in the turn it runs now, until that
14  // turn's own sum stands in its place.
15  live: number
16  // The context its last response read, what Claude Code calls its tokens;
17  // null until one has ended.
18  context: number | null
19  tools: Record<string, number>
20  startedAt: number
21  endedAt: number | null
22}
23
24export type Crew = Record<string, Mate>
25
26// A subagent stands done this long after it ends, then sleeps.
27export const DONE_MS = 14_000
28
29const SETTLED = ['completed', 'failed', 'killed']
30
31export const isSettled = (status: string) => SETTLED.includes(status)
32
33const fresh = (id: string, at: number, type = 'subagent', description = ''): Mate => ({
34  id,
35  type,
36  description,
37  status: 'running',
38  tokens: 0,
39  live: 0,
40  context: null,
41  tools: {},
42  startedAt: at,
43  endedAt: null,
44})
45
46// A subagent the engine has just started.
47export const joined = (crew: Crew, id: string, type: string, description: string, at: number): Crew => ({
48  ...crew,
49  [id]: { ...(crew[id] ?? fresh(id, at)), type, description },
50})
51
52// A tool it called; one not seen start joins the crew here.
53export const used = (crew: Crew, id: string, tool: string, at: number): Crew => {
54  const mate = crew[id] ?? fresh(id, at)
55
56  return { ...crew, [id]: { ...mate, tools: { ...mate.tools, [tool]: (mate.tools[tool] ?? 0) + 1 } } }
57}
58
59// The tokens of one of its responses, while its turn runs, and the context
60// that response read.
61export const stepped = (crew: Crew, id: string, tokens: number, context: number, at: number): Crew => {
62  const mate = crew[id] ?? fresh(id, at)
63
64  return { ...crew, [id]: { ...mate, live: (mate.live ?? 0) + tokens, context } }
65}
66
67// The tokens of one of its turns, which take the place of its responses'.
68export const spent = (crew: Crew, id: string, tokens: number, at: number): Crew => {
69  const mate = crew[id] ?? fresh(id, at)
70
71  return { ...crew, [id]: { ...mate, tokens: mate.tokens + tokens, live: 0 } }
72}
73
74// All it has counted: its turns', and its responses' in the turn it runs now.
75export const countOf = (mate: Mate) => mate.tokens + (mate.live ?? 0)
76
77type Listed = { id: string; type?: unknown; description?: unknown; status?: unknown }
78
79// How each stands by the engine's agent list. One the list has let go of
80// while it ran has ended; one that settles now ends now. One the list has and
81// the crew does not, still running, a spawn the mod did not see (it loaded
82// after, or was reloaded), joins as the list tells it; one that has ended is
83// a turn before's, which the list keeps a while after a new turn lets it go.
84export const settled = (crew: Crew, listed: readonly Listed[], at: number): Crew =>
85  Object.fromEntries(
86    [
87      ...Object.values(crew),
88      ...listed
89        .filter(({ id, status }) => crew[id] === undefined && !(typeof status === 'string' && isSettled(status)))
90        .map(({ id, type, description }) => fresh(id, at, typeof type === 'string' ? type : 'subagent', typeof description === 'string' ? description : '')),
91    ].map(mate => {
92      const seen = listed.find(({ id }) => id === mate.id)
93      const status = seen === undefined ? (isSettled(mate.status) ? mate.status : 'completed') : typeof seen.status === 'string' ? seen.status : mate.status
94      const type = mate.type === 'subagent' && typeof seen?.type === 'string' ? seen.type : mate.type
95      const description = mate.description === '' && typeof seen?.description === 'string' ? seen.description : mate.description
96
97      return [mate.id, { ...mate, type, description, status, endedAt: mate.endedAt ?? (isSettled(status) ? at : null) }]
98    }),
99  )
100
101export const isBusy = (crew: Crew) => Object.values(crew).some(({ status }) => !isSettled(status))
102
103// A new turn begins: those still running stay, the rest are the last turn's.
104export const newTurn = (crew: Crew): Crew => Object.fromEntries(Object.entries(crew).filter(([, mate]) => !isSettled(mate.status)))
105
106export const phaseOf = (mate: Mate, at: number): JobPhase =>
107  mate.status === 'failed' || mate.status === 'killed'
108    ? 'fail'
109    : !isSettled(mate.status)
110      ? 'work'
111      : mate.endedAt !== null && at - mate.endedAt < DONE_MS
112        ? 'done'
113        : 'sleep'
114
115// Those at work first, by when they started; then the rest, the latest ended first.
116export const lineUp = (crew: Crew) =>
117  Object.values(crew).sort((a, b) =>
118    isSettled(a.status) !== isSettled(b.status)
119      ? isSettled(a.status)
120        ? 1
121        : -1
122      : isSettled(a.status)
123        ? (b.endedAt ?? 0) - (a.endedAt ?? 0)
124        : a.startedAt - b.startedAt,
125  )
126
127// The tools it called, the most first, three at most.
128export const toolLine = (mate: Mate) =>
129  Object.entries(mate.tools)
130    .sort((a, b) => b[1] - a[1])
131    .slice(0, 3)
132    .map(([name, count]) => `${name.startsWith('mcp__') ? (name.split('__').at(-1) ?? name) : name}${count > 1 ? `×${count}` : ''}`)
133    .join(' · ')
134
135// How long it has worked, or worked: seconds, then minutes and seconds.
136export const workedFor = (mate: Mate, at: number) => {
137  const seconds = Math.max(0, Math.round(((mate.endedAt ?? at) - mate.startedAt) / 1000))
138
139  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
140}
141
142// A thin bar of its share of the most any of them counted, in its colour.
143export const meterSvg = (share: number, color: string, width: number, alt: string) => {
144  const filled = Math.max(0, Math.min(1, share)) * width
145
146  return {
147    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="4" viewBox="0 0 ${width} 4"><rect width="${width}" height="4" rx="2" fill="#8b919c" fill-opacity=".25"/><rect width="${filled.toFixed(1)}" height="4" rx="2" fill="${color}"/></svg>`,
148    alt,
149  }
150}
151