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…

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.
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.
| Where | What you see |
|---|---|
| Terminal | A 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 app | The 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. |
/tokens | A 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.
Six words for every day:
| Type | Result |
|---|---|
/tokens | Opens the usage pane |
/tokens style | Opens 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 agents | Opens a pane of the subagents of the turn, a card each with a crab dressed for its job |
/tokens context | Opens 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 list | Says what is chosen, how wide the band is, and what it left out |
/tokens help | Lists every word: these six, then the settings a line for each kind (look, parts, alerts, budgets, data), then the checks |
Most have a button in /tokens style too; colour levels, the background, other projects' budgets and reset are typed.
| Type | Result | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
/tokens classic · timeline · quiet · neon · split | Switches 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 off | Sets hours when nothing goes out to Windows, or drops them | ||||||||||
/tokens budget 10 · budget off | Sets a budget in dollars a day, or drops it | ||||||||||
/tokens budget week 50 · budget week off | Sets a budget in dollars a week, Monday to Sunday, or drops it | ||||||||||
/tokens budget month 200 · budget month off | Sets a budget in dollars a calendar month, or drops it | ||||||||||
/tokens budget project here 5 · budget project shop off | Sets a budget in dollars a day for a project (here is the folder the session runs in), or drops it | ||||||||||
/tokens export | Writes 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 now | Says how many session files are over 31 days old and no longer read; now deletes them | ||||||||||
/tokens reset | Back 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.
| Type | Result |
|---|---|
/tokens alerts test · alerts test week | Raises a sample alert for the 5-hour or the weekly window, and says how it went |
/tokens alerts test cache · alerts test lapsed | Brings 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.
Each is raised once per window:
Compact button/clear) where it is light, or Continue, which sends "Continue where you stopped." as your own message.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:
| Alert | Pose |
|---|---|
| past 60% | waves a claw |
| past 85% | wide eyes, a drop of sweat, a shiver |
| limit reached | behind bars, shaking them now and then |
| filling too fast | runs |
| window reset | both claws up, jumping |
| context past 80% | squeezed between two walls |
| budget nearly spent | wide eyes, a drop of sweat, a shiver |
| budget spent | behind 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.
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:
| News | Pose |
|---|---|
| a task is done (every turn's end) | beside a green tick sign |
| Claude asks you to choose | scratching its head under a question mark |
| Claude needs leave to use a tool | waiting at a barrier |
| ten minutes after a task, still unanswered | asleep |
a usage alert, unless /tokens alerts app keeps alerts in the app | the 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.
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./tokens alerts popup off keeps to plain notifications./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..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:
/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.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.
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.
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).
/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./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.
950, 11.3k, 412k, 1.2M).opus-5-5, haiku-5-5), a subagent's turns under its own model, five at most.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.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.full in 1d 12h). Half an hour of slope, which the 5-hour window goes by, says little about a week.reset 2h14m, at 14:14, with the weekday where it is not today).big task? after 15:20), in the place of the forecast./tokens context, or Details beside the line in /tokens, opens a pane of what the context holds, as /context counts it:
▨) and the free space (□). In a pane under 44 columns the rows go beneath the grid.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.~/.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.
/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),
hooks/register.tsx 758 lines1import { 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}
758hooks/cost.ts 701 lines1import 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}
701hooks/cache.ts 243 lines1import 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}
243hooks/advice.ts 174 lines1// 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}
174hooks/style.ts 518 lines1import 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}
518hooks/band.tsx 643 lines1// 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}
643hooks/ledger.ts 376 lines1// 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}
376hooks/measure.ts 142 lines1// 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}
142hooks/notices.ts 532 lines1// 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}
532hooks/pane-agents.tsx 202 lines1// 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}
202hooks/pane-context.tsx 204 lines1// 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}
204hooks/crew.ts 151 lines1// 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