Shows token use, plan limits and cache temperature of the sessions on this Mac

token-watch is a Claude Code mod. It shows the token use, the plan allowance and the cache temperature of the sessions on this Mac, live. It runs in the Claude Code CLI and in the Code tab of the Claude desktop app.
The mod only observes. It does not change, block or delay a request, a tool call or a prompt. It calls a model only for /token-watch recommend, after you confirm the cost in a dialog, and it sends only the usage data that the tabs show (see Recommendations). The only trace in the context is the short note that Claude Code records for each /token-watch, as for every slash command (see Limits). Its code is one hooks module of function hooks (hooks/register.ts) that Claude Code loads in its own process. It installs no hooks in settings.json.
cache life unknown · last request 13m ago in place of the tube, and COLD when 1 hour has passed.LIVE during a turn, then HOT, WARM, COOLING and COLD, with the minutes left.$2.40 to re-warm). When the cache is cold: the tokens and the cost that the next message writes again. A cost from a fallback price (see Limits) has a ≈: ≈ $2.40 to re-warm.week used up: usage credits until Sun 11:00: the weekly limit is at 100% or more.5h full at 15:31: pause or use Sonnet: the 5-hour limit fills within the next 60 minutes at the pace so far.slow down or use Sonnet: the week runs out before its reset at the pace so far.send now: after 14:32 the next message costs $2.40: the last 10 minutes of a 1-hour cache, when writing the context again costs $1 or more./clear if the topic changed: the cache is cold, and writing the context again costs $1 or more./compact: each message reads 640k ≈ $0.16: the context has 400k tokens or more.The model hint names the next smaller model: Fable or Opus use Sonnet, Sonnet use Haiku, Haiku none. While an action shows, the re-warm price, lasts until reset and the 5-hour limit leave the band.
week 41% · lasts until reset (dimmed), or week 76% · runs out Fri 14:00 in the heat colour when the week runs out before its reset at the pace so far. Then the 5-hour limit: 5h 12%. The pace runs from the start of the window of the limit up to the reading. An old reading shows its age: (2h ago).today $12.40 · $4.10/h, the cost of all sessions on this Mac since midnight and in the last 60 minutes.[ details ] and ×. [ details ] opens the pane, and while the pane is open it reads [ close ] and closes it. × hides the band in this session. In the desktop app a click presses them. In the terminal, ctrl+x tab moves the focus to [ details ], Enter or t presses it, and Tab moves the focus to ×. A press is not a slash command, so it adds nothing to the conversation.The band stays on one line. It takes the width that Claude Code gives it, keeps 4 cells free and 16 cells for the buttons, and leaves out parts when it is too wide. In the desktop app it counts each character of the text by its width in the font of the app, because the app gives the width in cells of its code font but draws the band in a proportional font. The order: the age of the limits, lasts until reset, the 5-hour limit, the re-warm price, the $/h, the context size, the week percent. The tube, the stage, the minutes, the action, a range that runs out and the buttons always stay.
/token-watch band off hides the band in all sessions on this Mac. /token-watch band on shows it again, and /token-watch band names the current state of this session. × hides the band only in the session where it is pressed, until the session ends or until /token-watch band on; the other sessions keep their band. The answer is a toast, not text in the transcript. The setting stays in the store of the mod until it changes, also after a restart. A running session applies a change from another session within 15 seconds. While the band is off or hidden, the mod still counts each request, the pane and /token-watch recommend work, and /token-watch is the only way to open the pane.
In the terminal, the tube is drawn with block characters. In the desktop app, the tube is an SVG, because the desktop app uses a proportional font. The bars of the pane follow the same rule: block characters in the terminal, SVG in the desktop app.
Claude Code writes the cache of a request with a life of 1 hour or 5 minutes. The main conversation on a subscription within its usage limits gets 1 hour. Above the limit, with an API key, on a cloud provider and with FORCE_PROMPT_CACHING_5M=1, it gets 5 minutes. A subagent gets 5 minutes. Settings and environment variables can change each of these.
The mod reads no settings and no environment variables. It reads the life from the session cost that Claude Code reports with /cost:
/token-watch or the band button opens a pane. The keys 1 to 5 select a tab. Esc, the band button or /token-watch closes the pane. The band button and /token-watch close it also when another pane covers it or when it waits for room.
| Tab | Content |
|---|---|
| Now | Each session on this Mac that ran the mod in the last 24 hours: cache tube, stage, minutes left, repo, model, context size, weighted cost in the last 60 minutes and today. |
| Session | This conversation by model and scope (main conversation or subagent type; a ≈ after the model name marks a cost from a fallback price): requests, input, cache write, cache read, output, cost and share, a total row labelled estimate (the requests that the mod saw, at API prices), a dimmed reported row with the cost that Claude Code reports with /cost, and a dimmed note that explains the difference. A cause table of the cache writes (start, growth, resume) with a share bar, tokens, cost and share. A cache history of the last 4 hours: a strip with one cell for each 5 minutes, a row resumes with a ▲ and the cost at each resume, and a time axis. |
| Week | A meter of the weekly percent, the reset time, a linear projection, week used, over time, and the cost of the week at API prices (on a subscription the value of the plan): the highest weekly percent of each of the 14 periods of 12 hours of the week, a dot or an outline for each period to come, and a day axis under it. Two cost tables, by repo and by model and scope, since the weekly reset, each with a share bar, cost and share. A ≈ after a name in the table by model and scope marks a cost from a fallback price. |
| Why | The context breakdown of this session: a context meter, the categories with a share bar, and lists of the largest memory files, MCP servers and custom agents, each with tokens and share. |
| Help | Static text that explains the band and every term of the other tabs: the tube, the stage words, the parts of the band, the columns and labels of each tab, the ≈ mark and unpriced, and the forms of /token-watch. Each term is drawn as it shows in the band or in its tab, and each has one line of explanation. |
When the pane is narrower than a table, the less important columns are left out in a fixed order.
Every money amount has two decimals and, from $1,000.00, a comma as thousands separator: $0.50, $432.64, $1,234.56.
/token-watch recommend asks a model for recommendations that lower the token use and the use of the plan allowance.
$.model.complete): one completion with no history and no tools, at effort medium, with a time limit of 2 minutes.recommend, so the Session and Week tabs show its cost. The call does not return the model id, so an alias counts as its family: sonnet ≈, at the price of the newest Sonnet in the price table.recommendModel in /config (row Model of /token-watch recommend): sonnet, opus or haiku, or a full model id. An alias resolves like --model to the newest model of its family. The default is sonnet. A model that Claude Code does not accept shows The request was not sent in the dialog, and no call runs. A comparison of Sonnet 5.5 and Opus 5.5 is in the design doc.turn.step event, for the main conversation and for each subagent.session.measure and $.session.usage()). The weekly percent is the figure of Anthropic.~/.claude/plugins/store/), at most every 15 seconds. Snapshots older than 8 days are deleted.settings. Each session reads it at the start and every 15 seconds./resume or /branch, the mod sets the cache time from the time since the last response that Claude Code passes, so a cold cache shows at once.Each hook passes its event on unchanged, with three exceptions that concern only the mod's own items: the hook of /token-watch and the hooks of the mod's two panes answer for themselves. The band hook adds its line above what Claude Code and other mods draw there.
| Event | What the hook does |
|---|---|
session.start | Starts the record of the conversation, reads the plan limits and the band setting, starts the timers and registers /token-watch. |
classic.SessionStart (clear, resume, fork) | Starts a new record for the new conversation. After /resume or /branch, it sets the cache time from the time since the last response. |
session.end | Writes the last snapshot to the store. |
turn.step | Reads the token use of each model request after the request, from the result. The request and its result stay unchanged. For a subagent request, it reads the subagent type from the agent list of the session ($.agent.list()), once for each subagent. |
turn.complete | Clears the working flag of the main conversation. |
session.measure | Saves the plan limits that Claude Code measured. |
command.run (token-watch only) | Answers the mod's own command: opens or closes the pane, with recommend opens the cost dialog, and with band on, band off or band sets or names the band setting in a toast. It adds no text to the transcript. |
ui.render (AbovePrompt) | Draws the band and its two buttons. What Claude Code and other mods draw above the prompt stays, below the band. While a survey shows, while the band is off or hidden in this session, or while it has no request, no limits and no tokens to show, the hook draws nothing. |
ui.render (Pane, the mod's own pane only) | Draws the pane that /token-watch opens. |
ui.render (Pane, the dialog of /token-watch recommend only) | Draws the cost dialog, the wait for the reply, and the reply. |
ui.close (the mod's own pane only) | Sets the label of the band button back to [ details ]. The pane closes. |
ui.close (the dialog of /token-watch recommend only) | Stops a model call that runs and clears the dialog. The dialog closes. |
turn.step, for example compaction summaries, are not counted. The totals can be lower than /cost.hooks/prices.ts (source: the Claude pricing page, read 2026-10-08). A price change needs an edit of that file. Haiku 5.5 has a higher price for a prompt above 100,000 tokens; the table holds both prices.claude-, for example opus). claude-opus-5-6 uses the price of claude-opus-5-5 while the table has no key for it. The cost then shows with a ≈ after the model name in the Session table and in the Week table by model and scope, and in the re-warm cost of the band. The Now tab and the totals mix models, so they have no ≈. A model of a family without any key shows unpriced, and its cost is 0.make price-report. It lists the models that the mod saw, each as exact, fallback from <key>, unpriced or alias, priced as <key>. An alias is the model of /token-watch recommend and needs no key. Read the pricing page, add the new keys to PRICES in hooks/prices.ts and to PRICE_KEYS in scripts/price-rule.mjs, and run make price-report again. make verify fails while the two lists differ. The costs that the mod already stored keep the price of the day that it counted them.cache life unknown and prices with the default life./token-watch adds the command to the conversation, as every slash command does: a short note of a few dozen tokens that the model reads with the next message. The band button opens and closes the pane without this note. The band, the pane, the dialog and the toasts are drawn only for the user. The mod sends data to a model only in the call of /token-watch recommend, and the reply of that call does not enter the conversation./token-watch recommend costs one model call. On a subscription the call counts against the plan allowance. With an API key it is billed at API prices. The cost in the dialog is an estimate.$.model.complete (a policy mod on plugin.register). There token-watch does not load.Claude Code 2.1.287 or later. Tested with 2.1.288 (automated tests), 2.1.291 (manual checks) and 2.1.294 (checks of the cache life).
A mod is not sandboxed. It runs with your permissions in the Claude Code process. Read hooks/register.ts before you install it, or run claude plugin validate on a clone: it lists each event that the mod hooks and each API call that it makes.
Install the mod from the marketplace of this repo:
claude plugin marketplace add arviaja/token-watch claude plugin install token-watch@token-watch
Sessions that start after the installation load the mod.
To run the mod from a clone, for one session:
claude --plugin-dir /path/to/token-watch
To run it from a clone for every session in the CLI and the desktop app, add the path to the env block of ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/token-watch" }
The setting applies to sessions that start after the change. The loaded mod is the code that is checked out in the clone.
Use one of these ways, not two. Two ways load the mod twice.
/token-watch band off hides only the band (see Band above the prompt). The commands below stop the mod itself. While the mod is not loaded, it counts nothing, so the Now and Week tabs miss these sessions. The commands apply to sessions that start after them.
Marketplace install:
| Action | Command |
|---|---|
| Turn off | claude plugin disable token-watch@token-watch |
| Turn on | claude plugin enable token-watch@token-watch |
| Uninstall | claude plugin uninstall token-watch@token-watch |
| Remove the marketplace | claude plugin marketplace remove token-watch |
Clone: remove CLAUDE_CODE_PLUGIN_DIRS from the env block of ~/.claude/settings.json, and add it again to turn the mod on. For one CLI session without the mod, give the variable an empty value with --settings:
claude --settings '{"env":{"CLAUDE_CODE_PLUGIN_DIRS":""}}'
A shell variable (CLAUDE_CODE_PLUGIN_DIRS= claude) does not work: the env block of settings.json replaces it when the session starts. The same flag with a path runs another clone or a worktree for one session, in place of the clone in settings.json.
The uninstall and the removal of the marketplace keep the store of the mod: the snapshots and the band setting, in one file for each way of installation. To delete the data, delete the files after the uninstall:
rm ~/.claude/plugins/store/token-watch_*.json
make verify runs these checks:
scripts/check-private.sh): home paths, and the terms of a local list that is never committedclaude plugin validate --strict .claude plugin test .Prerequisites: the claude CLI, gitleaks and perl.
make typecheck runs the TypeScript compiler through npx, so it also needs Node.js. It reports known errors, so it is not part of make verify yet.
make price-report runs node scripts/price-report.mjs. It needs Node.js and reads the store of the mod (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/store/). It prints each model that the mod saw as exact, fallback from <key>, unpriced or alias, priced as <key>, and a summary line. It informs and always exits 0, and it is not part of make verify.
Claude Code writes the type declarations of the installed version into .claude-plugin/types/ and a tsconfig.json when the mod loads. Both are not tracked.
See CONTRIBUTING.md. Report a security problem privately, as SECURITY.md describes. PRIVACY.md describes the data that the mod reads, stores and sends.
MIT. See LICENSE.
hooks/register.ts 661 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Counts, Run, Snapshot, Ttl } from '../types'
5import { repoName } from './format'
6import { costOf, matchLifetime, priceInfo, writeCostOf } from './prices'
7import { OUTPUT_CAP, RECOMMEND_EFFORT, RECOMMEND_SCOPE, RECOMMEND_SYSTEM, RECOMMEND_TIMEOUT_MS, drawableText, estimateTokens, failureText, maxCostOf, modelOption, priceModelOf, recommendPrompt, type CallUsage } from './recommend'
8import { NO_LIFETIME, TTL_MS, confirmLifetime, defaultTtl, ttlFromResume } from './temperature'
9import { KEEP_MS, NO_CAUSES, NO_MAIN, addCause, addReadings, addTo, breakdownOf, causeOf, contextOf, countsOf, hourKey, mainAfter, nowRows, parseSnapshot, requestsOf, rowsOf, runKey, snapshotOf, spendOf, sumAll } from './tally'
10import { bandData, bandEls, helpEls, nowEls, paneEls, recommendEls, sessionEls, tabsEls, weekData, weekEls, whyEls, type Els } from './view'
11
12const run = atom({ plugin: 'token-watch', key: 'run' } as const, null)
13const totals = atom({ plugin: 'token-watch', key: 'totals' } as const, {})
14const causes = atom({ plugin: 'token-watch', key: 'causes' } as const, NO_CAUSES)
15const main = atom({ plugin: 'token-watch', key: 'main' } as const, NO_MAIN)
16const limits = atom({ plugin: 'token-watch', key: 'limits' } as const, [])
17const limitsAt = atom({ plugin: 'token-watch', key: 'limitsAt' } as const, null)
18const readings = atom({ plugin: 'token-watch', key: 'readings' } as const, [])
19const agents = atom({ plugin: 'token-watch', key: 'agents' } as const, {})
20const threads = atom({ plugin: 'token-watch', key: 'threads' } as const, {})
21const threadTtls = atom({ plugin: 'token-watch', key: 'threadTtls' } as const, {})
22const lifetimes = atom({ plugin: 'token-watch', key: 'lifetimes' } as const, {})
23const hours = atom({ plugin: 'token-watch', key: 'hours' } as const, {})
24const breakdown = atom({ plugin: 'token-watch', key: 'breakdown' } as const, null)
25const others = atom({ plugin: 'token-watch', key: 'others' } as const, [])
26const tab = atom({ plugin: 'token-watch', key: 'tab' } as const, 1)
27const recommend = atom({ plugin: 'token-watch', key: 'recommend' } as const, null)
28const isBandOn = atom({ plugin: 'token-watch', key: 'isBandOn' } as const, true)
29const isPaneOpen = atom({ plugin: 'token-watch', key: 'isPaneOpen' } as const, false)
30const isBandHidden = atom({ plugin: 'token-watch', key: 'isBandHidden' } as const, false)
31
32// The pane's id, used to open the pane and to recognize it when drawing
33const PANE = 'token-watch'
34// The settings of all sessions on this Mac: { band: 'on' | 'off' }. band off hides the band, and a missing value shows it.
35// The key does not start with run:, so the clean-up and the list of the other sessions leave it alone
36const SETTINGS_KEY = 'settings'
37const BAND_OFF = 'Band off in all sessions on this Mac. The mod still counts. /token-watch band on shows it again.'
38const BAND_ON = 'Band on in all sessions on this Mac.'
39const BAND_IS_OFF = 'The band is off. /token-watch band on shows it.'
40const BAND_IS_ON = 'The band is on. /token-watch band off hides it.'
41const BAND_HIDDEN = 'Band hidden in this session. /token-watch band on shows it again.'
42const BAND_IS_HIDDEN = 'The band is hidden in this session. /token-watch band on shows it.'
43// The dialog of /token-watch recommend is a pane of its own, so the tabs keep their state
44const RECOMMEND_PANE = 'token-watch-recommend'
45const RECOMMEND_TITLE = 'token-watch recommend'
46const STALE_ASK = 'The call stopped: the mod loaded again while the call ran. Run /token-watch recommend again.'
47
48// The module's own flags start over on a reload; the data lives in $.state and $.store
49let isDirty = false
50// Stops the running call of /token-watch recommend when the dialog closes
51let stopRecommend: AbortController | null = null
52// The last session cost that the mod has read, in USD. A module variable and not $.state: bookedSince reads and writes it with no await
53// in between, so two requests that end together never both count from one old value. A reload starts it over; the next reading sets it again
54let costSeen: number | null = null
55
56// The cost that Claude Code booked since the last reading of any request, or null. A lower reading (the session cost started again,
57// or a late reading) starts the count over. The first reading only sets the start
58function bookedSince(usd: number | null): number | null {
59 if (usd === null) return null
60 const before = costSeen
61 costSeen = usd
62 if (before === null || usd < before) return null
63 return usd - before
64}
65
66async function newRun($: any): Promise<Run> {
67 const next: Run = { sessionId: await $.session.id(), startedAt: await $.clock.now(), repo: repoName(await $.session.root()) }
68 await update($, run, () => next)
69 return next
70}
71
72async function ensureRun($: any): Promise<Run> {
73 return (await read($, run)) ?? newRun($)
74}
75
76async function flush($: any): Promise<boolean> {
77 try {
78 const current = await read($, run)
79 if (current === null) return true
80 const m = await read($, main)
81 const h = await read($, hours)
82 // A conversation writes its key after its first request
83 if (m.lastRequestAt === null && Object.keys(h).length === 0) return true
84 const now = await $.clock.now()
85 const snapshot = snapshotOf(current, m, await read($, readings), h, now)
86 await $.store.set(runKey(current), snapshot)
87 return true
88 } catch {
89 // The store is full or not available; the next tick tries again
90 return false
91 }
92}
93
94async function tick($: any): Promise<void> {
95 if (isDirty) {
96 isDirty = false
97 if (!(await flush($))) isDirty = true
98 }
99 if ((await isOthersShown($)) || (await isSpendShown($))) await loadOthers($)
100 await loadSettings($)
101}
102
103function messageOf(error: unknown): string {
104 return error instanceof Error ? error.message : String(error)
105}
106
107async function isPaneShown($: any): Promise<boolean> {
108 try {
109 return (await $.ui.panes()).some((p: { id: string; isShown: boolean }) => p.id === PANE && p.isShown)
110 } catch {
111 return false
112 }
113}
114
115// The list of the other conversations is read only while the pane shows the Now or the Week tab
116async function isOthersShown($: any): Promise<boolean> {
117 if (!(await isPaneShown($))) return false
118 const n = await read($, tab)
119 return n === 1 || n === 3
120}
121
122// The band shows the spend of all sessions in place of the limits when a request came back without plan limits: an API key.
123// A subscription has its limits from the first response on. Only then does the tick read the snapshots of the other sessions for the band
124async function isSpendShown($: any): Promise<boolean> {
125 if ((await read($, limits)).length > 0 || (await read($, main)).lastRequestAt === null) return false
126 return (await read($, isBandOn)) && !(await read($, isBandHidden))
127}
128
129// A missing or unknown value shows the band
130function isBandOnIn(settings: unknown): boolean {
131 return !(typeof settings === 'object' && settings !== null && (settings as { band?: unknown }).band === 'off')
132}
133
134// Another session can change the setting, so the tick reads it again
135async function loadSettings($: any): Promise<void> {
136 try {
137 const isOn = isBandOnIn(await $.store.get(SETTINGS_KEY))
138 if ((await read($, isBandOn)) === isOn) return
139 await update($, isBandOn, () => isOn)
140 $.ui.invalidate('ui.render')
141 } catch {
142 // Keep the last value; the next tick reads again
143 }
144}
145
146// The store holds the setting for all sessions. A failed write changes nothing, because the next tick reads the store again
147async function setBand($: any, isOn: boolean): Promise<void> {
148 // band on also shows a band that the × hid in this session. That needs no store, so it holds also when the write fails
149 if (isOn) await update($, isBandHidden, () => false)
150 try {
151 const current = await $.store.get(SETTINGS_KEY)
152 const base = typeof current === 'object' && current !== null && !Array.isArray(current) ? current : {}
153 await $.store.set(SETTINGS_KEY, { ...base, band: isOn ? 'on' : 'off' })
154 } catch (error) {
155 $.ui.invalidate('ui.render')
156 $.ui.toast('The band setting was not saved: ' + messageOf(error))
157 return
158 }
159 await update($, isBandOn, () => isOn)
160 $.ui.invalidate('ui.render')
161 $.ui.toast(isOn ? BAND_ON : BAND_OFF)
162}
163
164// The × of the band: this session only. The store and the other sessions stay unchanged
165async function hideBand($: any): Promise<void> {
166 await update($, isBandHidden, () => true)
167 $.ui.invalidate('ui.render')
168 $.ui.toast(BAND_HIDDEN)
169}
170
171async function bandStateText($: any): Promise<string> {
172 if (!(await read($, isBandOn))) return BAND_IS_OFF
173 return (await read($, isBandHidden)) ? BAND_IS_HIDDEN : BAND_IS_ON
174}
175
176// The band button reads the flag for its label, so each open and close sets it. The flag means that the pane is up: shown, covered by another pane, or waiting for room
177async function markPane($: any, isOpen: boolean): Promise<void> {
178 await update($, isPaneOpen, () => isOpen)
179 $.ui.invalidate('ui.render')
180}
181
182// Returns why the pane did not open, or null
183async function openPane($: any): Promise<string | null> {
184 await loadOthers($)
185 if ((await read($, tab)) === 4) await loadBreakdown($)
186 try {
187 const opened = await $.ui.open({ id: PANE, title: 'token-watch', focus: true, closeOnEscape: true, columns: 80, rows: 24 })
188 await markPane($, true)
189 if (opened.isPlaced === false) return 'The token-watch pane is waiting: ' + (opened.reason ?? 'no reason given')
190 } catch (error) {
191 // A refused open must not throw into the session: say why instead
192 return 'The token-watch pane did not open: ' + messageOf(error)
193 }
194 return null
195}
196
197// The engine's list of panes, or the flag when the list is not available
198async function isPaneUp($: any): Promise<boolean> {
199 try {
200 return (await $.ui.panes()).some((p: { id: string }) => p.id === PANE)
201 } catch {
202 return read($, isPaneOpen)
203 }
204}
205
206// A pane that is up closes, also when another pane covers it or it waits for room, so the label [ close ] always closes it.
207// The mod's own $.ui.close does not run its own ui.close hook, so this function marks the pane closed itself
208async function togglePane($: any): Promise<string | null> {
209 if (!(await isPaneUp($))) return openPane($)
210 try {
211 await $.ui.close({ id: PANE })
212 } catch (error) {
213 return 'The token-watch pane did not close: ' + messageOf(error)
214 }
215 await markPane($, false)
216 return null
217}
218
219// The band button is not a slash command, so a press adds nothing to the conversation. A message shows as a toast
220async function pressPane($: any): Promise<void> {
221 const message = await togglePane($)
222 if (message !== null) $.ui.toast(message)
223}
224
225async function loadOthers($: any): Promise<void> {
226 try {
227 const list = []
228 for (const key of await $.store.keys()) {
229 if (!key.startsWith('run:')) continue
230 const snapshot = parseSnapshot(await $.store.get(key))
231 if (snapshot !== null) list.push(snapshot)
232 }
233 await update($, others, () => list)
234 } catch {
235 // Keep the last list
236 }
237}
238
239async function prune($: any): Promise<void> {
240 try {
241 const now = await $.clock.now()
242 for (const key of await $.store.keys()) {
243 if (!key.startsWith('run:')) continue
244 const snapshot = parseSnapshot(await $.store.get(key))
245 if (snapshot === null || snapshot.updatedAt < now - KEEP_MS) await $.store.delete(key)
246 }
247 } catch {
248 // The next session start tries again
249 }
250}
251
252// readAt is the time of the API response that reported the limits. Without it, the limits count as read now
253async function saveLimits($: any, list: readonly { kind: string; percentUsed: number; resetsAt?: string }[], readAt?: number): Promise<void> {
254 if (list.length === 0) return
255 const at = readAt ?? (await $.clock.now())
256 await update($, limits, () => list.map((l) => ({ ...l })))
257 await update($, limitsAt, () => at)
258 await update($, readings, (r) => addReadings(r, list, at))
259 isDirty = true
260}
261
262async function loadLimits($: any, readAt?: number): Promise<void> {
263 try {
264 const usage = await $.session.usage()
265 await saveLimits($, usage.rateLimits ?? [], readAt)
266 } catch {
267 // The first session.measure brings the limits
268 }
269}
270
271async function loadBreakdown($: any): Promise<void> {
272 try {
273 const usage = await $.session.usage({ breakdown: 'summary' })
274 const input = usage.context.breakdown
275 if (input) await update($, breakdown, () => breakdownOf(input))
276 } catch {
277 // Tab 4 keeps its wait text
278 }
279}
280
281async function selectTab($: any, n: number): Promise<void> {
282 await update($, tab, () => n)
283 if (n === 4) await loadBreakdown($)
284 if (n === 1 || n === 3) await loadOthers($)
285}
286
287// The snapshots of the store, with this conversation's fresh data in place of its stored copy
288async function allSnapshots($: any, now: number): Promise<Snapshot[]> {
289 const current = await read($, run)
290 const stored = await read($, others)
291 if (current === null) return [...stored]
292 const own = snapshotOf(current, await read($, main), await read($, readings), await read($, hours), now)
293 return [...stored.filter((s) => s.key !== own.key), own]
294}
295
296async function sessionCost($: any): Promise<number | null> {
297 try {
298 const usage = await $.session.usage()
299 return usage.cost?.usd ?? null
300 } catch {
301 return null
302 }
303}
304
305// The type of a subagent comes from the agent list of the session, once for each agent.
306// An agent that the list does not hold (a workflow's) keeps the scope subagent.
307async function agentTypeOf($: any, agentId: string): Promise<string> {
308 const known = (await read($, agents))[agentId]
309 if (known !== undefined) return known
310 let listed: { id: string; type: string }[]
311 try {
312 listed = await $.agent.list()
313 } catch {
314 // The request still counts, under the scope subagent; the next request asks again
315 return 'subagent'
316 }
317 const type = listed.find((a) => a.id === agentId)?.type || 'subagent'
318 await update($, agents, (a) => ({ ...a, [agentId]: type }))
319 return type
320}
321
322// The dialog of /token-watch recommend: it reads the data of the tabs, builds the prompt and shows the cost. No call runs here
323async function openRecommend($: any, model: string): Promise<{ text?: string }> {
324 // A new dialog takes the place of the old one, so the reply of a call that runs has no place to show
325 stopRecommend?.abort()
326 const now = await $.clock.now()
327 await loadOthers($)
328 await loadBreakdown($)
329 const m = await read($, main)
330 const prompt = recommendPrompt({
331 now,
332 totals: await read($, totals),
333 causes: await read($, causes),
334 usd: await sessionCost($),
335 requests: requestsOf(m),
336 mainTtl: m.ttl ?? null,
337 resumes: m.resumes,
338 limits: await read($, limits),
339 limitsAt: await read($, limitsAt),
340 week: weekData(await allSnapshots($, now), now),
341 breakdown: await read($, breakdown),
342 })
343 const priceModel = priceModelOf(model)
344 const inputTokens = estimateTokens(RECOMMEND_SYSTEM + prompt)
345 await update($, recommend, () => ({ id: now, phase: 'confirm' as const, model, priceModel, prompt, inputTokens, outputCap: OUTPUT_CAP, maxCost: maxCostOf(priceInfo(priceModel), inputTokens, OUTPUT_CAP), text: '', counts: null }))
346 try {
347 // A dialog: it takes the keys, Esc closes it, and the toasts wait until it closes
348 const opened = await $.ui.open({ id: RECOMMEND_PANE, title: RECOMMEND_TITLE, focus: true, closeOnEscape: true, holdToasts: true, columns: 80, rows: 18 })
349 if (opened.isPlaced === false) return { text: 'The token-watch recommend dialog is waiting: ' + (opened.reason ?? 'no reason given') }
350 } catch (error) {
351 return { text: 'The token-watch recommend dialog did not open: ' + messageOf(error) }
352 }
353 return {}
354}
355
356// The call runs only from the Ask button of the dialog
357async function askRecommend($: any): Promise<void> {
358 const r = await read($, recommend)
359 if (r === null || r.phase !== 'confirm') return
360 // Only one press moves the dialog from confirm to asking, so two quick presses run one call
361 let isStarted = false
362 await update($, recommend, (v) => {
363 isStarted = v !== null && v.id === r.id && v.phase === 'confirm'
364 return isStarted ? { ...v!, phase: 'asking' as const } : v
365 })
366 if (!isStarted) return
367 try {
368 // The pane stays open for the reply: it no longer holds the toasts, and it asks for more rows
369 await $.ui.open({ id: RECOMMEND_PANE, title: RECOMMEND_TITLE, closeOnEscape: true, columns: 80, rows: 24 })
370 } catch {
371 // The pane keeps its size
372 }
373 const stop = new AbortController()
374 stopRecommend = stop
375 let result: { isAnswered: boolean; text?: string; reason?: string; status?: number | null; error?: string; usage?: CallUsage }
376 try {
377 result = await $.model.complete({ model: r.model, system: RECOMMEND_SYSTEM, prompt: r.prompt, maxTokens: r.outputCap, effort: RECOMMEND_EFFORT, timeoutMs: RECOMMEND_TIMEOUT_MS }, { signal: stop.signal })
378 } catch (error) {
379 // The engine refused to send the request, for example for a model that is not allowed. No call ran
380 await update($, recommend, (v) => (v?.id === r.id ? { ...v, phase: 'failed' as const, text: drawableText('The request was not sent: ' + messageOf(error)) } : v))
381 return
382 } finally {
383 if (stopRecommend === stop) stopRecommend = null
384 }
385 const counts = await countRecommend($, r.priceModel, result.usage)
386 const isAnswered = result.isAnswered && typeof result.text === 'string'
387 await update($, recommend, (v) => (v?.id === r.id ? { ...v, phase: isAnswered ? ('answered' as const) : ('failed' as const), text: isAnswered ? drawableText(result.text!) : failureText(result), counts } : v))
388}
389
390// The usage of the call goes into the totals and the hours under the scope recommend, so the Session and the Week tab show its cost.
391// The call does not use the cache of a conversation, so a cache write has the 5-minute price, as in a subagent
392async function countRecommend($: any, priceModel: string, usage: CallUsage | undefined): Promise<Counts | null> {
393 if (!usage) return null
394 const record = { model: priceModel, ...usage }
395 const counts = countsOf(record, costOf(record, '5m'))
396 if (counts.input + counts.output + counts.cacheRead + counts.cacheWrite === 0) return null
397 const at = await $.clock.now()
398 await update($, totals, (t) => addTo(t, priceModel, RECOMMEND_SCOPE, counts))
399 await update($, hours, (h) => addTo(h, hourKey(at), priceModel + '|' + RECOMMEND_SCOPE, counts))
400 isDirty = true
401 return counts
402}
403
404// A close of the dialog stops a call that runs, because its reply has no place to show.
405// The mod's own $.ui.close does not pass its own ui.close hook, so Cancel does the same work as the hook
406async function endRecommend($: any): Promise<void> {
407 stopRecommend?.abort()
408 await update($, recommend, () => null)
409}
410
411// A reload of the module drops a call that runs, with the old module. The dialog then says so, instead of waiting for a reply that does not come
412async function dropStaleAsk($: any): Promise<void> {
413 await update($, recommend, (v) => (v?.phase === 'asking' ? { ...v, phase: 'failed' as const, text: STALE_ASK } : v))
414}
415
416async function closeRecommend($: any): Promise<void> {
417 await endRecommend($)
418 await $.ui.close({ id: RECOMMEND_PANE })
419}
420
421// The state of the old conversation must not reach the new one
422async function resetConversation($: any): Promise<void> {
423 await update($, totals, () => ({}))
424 await update($, causes, () => NO_CAUSES)
425 await update($, main, () => NO_MAIN)
426 await update($, threads, () => ({}))
427 await update($, threadTtls, () => ({}))
428 await update($, agents, () => ({}))
429 await update($, readings, () => [])
430 await update($, hours, () => ({}))
431 await update($, limits, () => [])
432 await update($, limitsAt, () => null)
433 await update($, breakdown, () => null)
434}
435
436// A resumed conversation brings the time of its last response, so the cache temperature is right at once
437async function seedResumed($: any, e: { seconds_since_last_response?: unknown; prompt_cache_likely_expired?: unknown; context_tokens?: unknown; model?: unknown }): Promise<void> {
438 const seconds = e.seconds_since_last_response
439 const context = e.context_tokens
440 if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return
441 if (typeof context !== 'number' || !Number.isFinite(context) || context <= 0) return
442 const at = (await $.clock.now()) - seconds * 1000
443 let model = typeof e.model === 'string' && e.model !== '' ? e.model : ''
444 // The resume event of some builds has no model; the session knows it
445 if (model === '') {
446 try {
447 const current = await $.session.model()
448 if (typeof current === 'string' && current !== '') model = current
449 } catch {
450 // The model stays empty until the first request
451 }
452 }
453 // Claude Code says whether the cache expired, which can prove the life of the last cache. It proves nothing about the life of the next requests
454 const ttl = ttlFromResume(seconds, e.prompt_cache_likely_expired)
455 await update($, main, (m) => ({ ...m, lastRequestAt: at, ttl, contextTokens: context, ...(model !== '' ? { model } : {}) }))
456 await update($, threads, (t) => ({ ...t, main: at }))
457 if (ttl !== null) await update($, threadTtls, (t) => ({ ...t, main: ttl }))
458}
459
460export const register: Register = (on, options) => {
461 // A change of the option in /config loads the module again
462 const recommendModel = modelOption(options)
463
464 on('session.start', async ($, e, next) => {
465 try {
466 await ensureRun($)
467 isDirty = true
468 await loadLimits($)
469 await loadSettings($)
470 // session.start also runs after a reload of the module
471 await dropStaleAsk($)
472 // The pane can stay open over a reload of the module
473 const isOpen = await isPaneUp($)
474 await update($, isPaneOpen, () => isOpen)
475 } catch {
476 // The timers and the command are still registered
477 }
478 $.clock.after(5_000, () => {
479 void prune($)
480 })
481 $.clock.every(30_000, () => {
482 $.ui.invalidate('ui.render')
483 })
484 $.clock.every(15_000, () => {
485 void tick($)
486 })
487 try {
488 await $.command.register({ name: 'token-watch', description: 'Show token use, plan limits and cache temperature', argumentHint: '[recommend | band on | band off]', immediate: true })
489 } catch {
490 // The name is taken after a reload; the command from the first load stays
491 }
492 return next(e)
493 })
494
495 // A new conversation has empty state and gets its own store key
496 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
497 // The limits of the session come from its last API response, which can be older than the new conversation: they keep the time of the last reading
498 const lastReadAt = await read($, limitsAt)
499 costSeen = null
500 await newRun($)
501 await resetConversation($)
502 if (e.source === 'resume' || e.source === 'fork') await seedResumed($, e)
503 isDirty = true
504 await loadLimits($, lastReadAt ?? undefined)
505 return next(e)
506 }).catch(($, e, next) => next(e)) // Observation only; the session start goes on
507
508 on('session.end', async ($, e, next) => {
509 await flush($)
510 return next(e)
511 })
512
513 // Observes each model request; the result goes back unchanged
514 on('turn.step', async function* ($, e, next) {
515 const agentId = e.agentId
516 let requestAt: number | undefined
517 try {
518 requestAt = await $.clock.now()
519 // The session cost before the request, so that a booking from before the request does not count for it
520 bookedSince(await sessionCost($))
521 if (agentId === undefined) await update($, main, (m) => ({ ...m, isWorking: true }))
522 } catch {
523 // Observation only; the request goes on
524 }
525 const result = yield* next(e)
526 try {
527 const usage = result?.usage
528 if (usage) {
529 // The session cost first, before any other await, so that few other bookings come in between
530 const booked = bookedSince(await sessionCost($))
531 const at: number = requestAt ?? (await $.clock.now())
532 const isSubagent = agentId !== undefined
533 const scope = isSubagent ? await agentTypeOf($, agentId) : 'main'
534 const thread = agentId ?? 'main'
535 // The life of the cache writes: the one whose price gives the booked cost, confirmed by confirmLifetime. Before the first match, the default of Claude Code
536 const match = matchLifetime(usage, booked)
537 const written = await update($, lifetimes, (l) => ({ ...l, [scope]: confirmLifetime(l[scope] ?? NO_LIFETIME, match) }))
538 const known: Ttl | null = written[scope]?.known ?? null
539 const ttl = known ?? defaultTtl(isSubagent)
540 const counts = countsOf(usage, costOf(usage, ttl))
541 // A request that neither reads nor writes the cache leaves the cache as it was
542 const isCached = counts.cacheRead + counts.cacheWrite > 0
543 // The gap counts against the life of the cache that the previous request of the thread left
544 const previousTtl = (await read($, threadTtls))[thread] ?? ttl
545 const cause = causeOf((await read($, threads))[thread], at, TTL_MS[previousTtl])
546 const writeCost = writeCostOf(usage, ttl)
547 await update($, totals, (t) => addTo(t, usage.model, scope, counts))
548 await update($, causes, (c) => addCause(c, cause, counts.cacheWrite, writeCost))
549 if (isCached) {
550 await update($, threads, (t) => ({ ...t, [thread]: at }))
551 await update($, threadTtls, (t) => ({ ...t, [thread]: ttl }))
552 }
553 await update($, hours, (h) => addTo(h, hourKey(at), usage.model + '|' + scope, counts))
554 if (!isSubagent) await update($, main, (m) => mainAfter(m, usage.model, at, contextOf(counts), cause, writeCost, ttl, known, isCached))
555 isDirty = true
556 }
557 } catch {
558 // Observation only; the request result stands
559 }
560 return result
561 })
562
563 // At a model switch Claude Code names the cache life of the main conversation. That is a reading, so it replaces the life from the costs
564 on('classic.PostModelSwitch', async ($, e, next) => {
565 const ttl = e.cache_ttl
566 if (ttl === '5m' || ttl === '1h') await update($, lifetimes, (l) => ({ ...l, main: { known: ttl, pending: null } }))
567 return next(e)
568 }).catch(($, e, next) => next(e)) // Observation only; the switch goes on
569
570 on('turn.complete', async ($, e, next) => {
571 try {
572 if (e.agentId === undefined) {
573 await update($, main, (m) => ({ ...m, isWorking: false }))
574 isDirty = true
575 }
576 } catch {
577 // Observation only
578 }
579 return next(e)
580 })
581
582 on('session.measure', async ($, e, next) => {
583 try {
584 await saveLimits($, e.rateLimits ?? [])
585 } catch {
586 // Observation only
587 }
588 return next(e)
589 })
590 on('command.run', { command: 'token-watch' }, async ($, e) => {
591 const args = e.args.trim().toLowerCase().split(/\s+/).join(' ')
592 if (args === 'recommend') return openRecommend($, recommendModel)
593 // The band setting answers with a toast, so the transcript gets no text
594 if (args === 'band on' || args === 'band off') {
595 await setBand($, args === 'band on')
596 return {}
597 }
598 if (args === 'band') {
599 $.ui.toast(await bandStateText($))
600 return {}
601 }
602 const message = await togglePane($)
603 // Print nothing in the transcript, unless the pane did not open
604 return message === null ? {} : { text: message }
605 })
606
607 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
608 if (e.props.hasSurvey || !(await read($, isBandOn)) || (await read($, isBandHidden))) return next(e)
609 const now = await $.clock.now()
610 const m = await read($, main)
611 const planLimits = await read($, limits)
612 const spend = planLimits.length === 0 && m.lastRequestAt !== null ? spendOf(await allSnapshots($, now), now) : null
613 const data = bandData(m, planLimits, await read($, limitsAt), now, spend)
614 if (data === null) return next(e)
615 const E = $.ui.resolve(e) as unknown as Els
616 // What the mods after this one draw in the band stays, below this line
617 const theirs = await next(e)
618 const line = bandEls(E, data, e.surface, e.props.bodyColumns, { isPaneOpen: await read($, isPaneOpen), onPane: () => pressPane($), onHide: () => hideBand($) })
619 return (theirs ? E.Box({ flexDirection: 'column', children: [line, theirs] }) : line) as never
620 })
621
622 // Esc and the close mark of the person: the band button reads details again
623 on('ui.close', { id: PANE }, async ($, e, next) => {
624 await markPane($, false)
625 return next(e)
626 }).catch(($, e, next) => next(e)) // The pane closes in all cases
627
628 // Esc and the close mark of the person
629 on('ui.close', { id: RECOMMEND_PANE }, async ($, e, next) => {
630 await endRecommend($)
631 return next(e)
632 }).catch(($, e, next) => next(e)) // The pane closes in all cases
633
634 on('ui.render', { component: 'Pane', requestId: RECOMMEND_PANE }, async ($, e) => {
635 const E = $.ui.resolve(e) as unknown as Els
636 return recommendEls(E, await read($, recommend), { onAsk: () => askRecommend($), onCancel: () => closeRecommend($) }, e.props.bodyColumns) as never
637 })
638
639 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
640 const E = $.ui.resolve(e) as unknown as Els
641 const current = await read($, tab)
642 const tabs = tabsEls(E, current, (n) => selectTab($, n))
643 // Tab 5 is static text: it reads no clock, no store and no state besides the tab
644 if (current === 5) return paneEls(E, tabs, helpEls(E, e.props.bodyColumns, e.surface)) as never
645 const now = await $.clock.now()
646 let body: unknown
647 if (current === 2) {
648 const t = await read($, totals)
649 const m = await read($, main)
650 body = sessionEls(E, { rows: rowsOf(t), total: sumAll(t), causes: await read($, causes), requests: requestsOf(m), resumes: m.resumes, now, usd: await sessionCost($) }, e.props.bodyColumns, e.surface)
651 } else if (current === 4) {
652 body = whyEls(E, await read($, breakdown), e.props.bodyColumns, e.surface)
653 } else {
654 const list = await allSnapshots($, now)
655 const r = await read($, run)
656 body = current === 3 ? weekEls(E, weekData(list, now), e.props.bodyColumns, e.surface) : nowEls(E, nowRows(list, r === null ? '' : runKey(r), now), now, e.surface, e.props.bodyColumns)
657 }
658 return paneEls(E, tabs, body) as never
659 })
660}
661hooks/format.ts 279 lines1import type { Counts, Limit, Reading, Ttl } from '../types'
2import { minutesCold, minutesLeft, type Stage } from './temperature'
3
4export type Align = 'left' | 'right'
5export type Column = { width: number; align: Align }
6export type HistoryCell = { char: string; percent: number | null; isFuture: boolean }
7
8const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
9const SPARK = '▁▂▃▄▅▆▇█'
10const HISTORY_MS = 12 * 3_600_000
11const HISTORY_CELLS = 14
12const LIMIT_LABELS: Record<string, string> = { seven_day: 'week', five_hour: '5h', spend_limit: 'spend' }
13const LIMIT_ORDER = ['seven_day', 'five_hour', 'spend_limit']
14// The window of a limit ends at its reset. The spend limit has no window, so it has no projection
15const LIMIT_WINDOWS: Record<string, number> = { seven_day: 7 * 24 * 3_600_000, five_hour: 5 * 3_600_000 }
16
17export function formatTokens(n: number): string {
18 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
19 if (n < 1000) return String(Math.round(n))
20 for (const [divisor, unit] of [
21 [1e3, 'k'],
22 [1e6, 'M'],
23 [1e9, 'B'],
24 ] as const) {
25 const value = n / divisor
26 if (value < 99.95) return value.toFixed(1) + unit
27 if (value < 999.5) return Math.round(value) + unit
28 }
29 return Math.round(n / 1e9) + 'B'
30}
31
32export function formatMoney(x: number): string {
33 if (typeof x !== 'number' || !Number.isFinite(x) || x <= 0) return '$0.00'
34 const [whole, cents] = x.toFixed(2).split('.')
35 return '$' + whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + cents
36}
37
38export function formatPercent(p: number): string {
39 return String(Math.round(p * 10) / 10) + '%'
40}
41
42export function shortModel(id: string): string {
43 if (typeof id !== 'string' || id === '') return 'unknown'
44 return id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
45}
46
47export function repoName(root: string): string {
48 const trimmed = (root ?? '').replace(/\/\.(claude\/)?worktrees\/[^/]+\/?$/, '').replace(/\/+$/, '')
49 const name = trimmed.split('/').pop() ?? ''
50 return name === '' ? 'unknown' : name
51}
52
53// The desktop app counts the room of a tree in cells of its code font, but it draws Text in its proportional font, Anthropic Sans.
54// These are the widths of the printable ASCII characters in that font, in hundredths of a code-font cell, from code 32 (space) to 126 (~):
55// the advance of Text Regular (of Bold for a capital, which the stage words use) times 1.62 cells per em, rounded up.
56// The 1.62 comes from a screenshot of the desktop app: a column of 63 cells in the Week tab, and the band text drawn beside it
57const DESKTOP_ASCII = [
58 // space ! " # $ % & ' ( ) * + , - . /
59 35, 39, 67, 104, 85, 161, 111, 39, 66, 66, 80, 104, 39, 59, 39, 55,
60 // 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
61 98, 61, 95, 91, 100, 92, 93, 87, 91, 93, 39, 39, 104, 104, 104, 89,
62 // @ A to O
63 142, 125, 109, 126, 124, 106, 99, 131, 123, 48, 94, 120, 96, 153, 129, 133,
64 // P to Z [ \ ] ^ _
65 104, 133, 114, 103, 96, 119, 125, 172, 120, 117, 110, 66, 55, 66, 104, 82,
66 // ` a to o
67 82, 89, 101, 90, 101, 94, 68, 93, 97, 41, 42, 92, 41, 142, 97, 95,
68 // p to z { | } ~
69 101, 101, 66, 84, 68, 96, 92, 134, 93, 93, 82, 66, 50, 66, 104,
70]
71const DESKTOP_OTHER: Record<string, number> = { '·': 39, '→': 133, '≈': 104, '…': 163 }
72// A character outside both tables counts as 2.2 cells: wider than W (1.72) and than a full-width glyph (1 em, 1.62), and as wide as an emoji of 1.35 em
73const DESKTOP_WIDEST = 220
74
75// The width of a text on the desktop, in cells of its code font
76export function desktopCells(text: string): number {
77 let sum = 0
78 for (const ch of text) {
79 const code = ch.codePointAt(0) ?? 0
80 sum += (code >= 32 && code <= 126 ? DESKTOP_ASCII[code - 32] : DESKTOP_OTHER[ch]) ?? DESKTOP_WIDEST
81 }
82 return sum / 100
83}
84
85// Pads a text to the column width; a cut text ends in an ellipsis, and one space always stays free
86export function cell(text: string, column: Column): string {
87 let chars = Array.from(text)
88 const room = column.width - 1
89 if (chars.length > room) chars = [...chars.slice(0, Math.max(0, room - 1)), '…']
90 const gap = ' '.repeat(column.width - chars.length)
91 return column.align === 'right' ? gap + chars.join('') : chars.join('') + gap
92}
93
94// The mark after the name of a model whose cost comes from a fallback price
95export const ESTIMATE_MARK = ' ≈'
96
97// A name with the estimate mark after it. A name that is too long for the column is cut with an ellipsis, and the mark stays whole.
98// The result fits the column with the one free cell that cell() keeps, so cell() does not cut it again
99export function markedCell(name: string, column: Column): string {
100 const room = column.width - 1
101 const chars = Array.from(name)
102 const mark = Array.from(ESTIMATE_MARK).length
103 if (chars.length + mark <= room) return name + ESTIMATE_MARK
104 return chars.slice(0, Math.max(0, room - mark - 1)).join('') + '…' + ESTIMATE_MARK
105}
106
107// The indexes of the columns that stay: while the table is wider than the room, columns go in the drop order
108export function fitColumns(widths: number[], dropOrder: number[], available: number | undefined): number[] {
109 const kept = widths.map((_, i) => i)
110 if (typeof available !== 'number' || !Number.isFinite(available)) return kept
111 let total = widths.reduce((s, w) => s + w, 0)
112 for (const i of dropOrder) {
113 if (total <= available) break
114 const at = kept.indexOf(i)
115 if (at === -1) continue
116 kept.splice(at, 1)
117 total -= widths[i]
118 }
119 return kept
120}
121
122// The items joined by commas. A list that is too long keeps its last items after an ellipsis
123export function fitList(items: string[], room: number): string {
124 const all = items.join(', ')
125 const length = (text: string) => Array.from(text).length
126 if (length(all) <= room) return all
127 let kept = ''
128 for (let i = items.length - 1; i >= 0; i--) {
129 const next = kept === '' ? items[i] : items[i] + ', ' + kept
130 if (length('… ' + next) > room) break
131 kept = next
132 }
133 return kept === '' ? '…' : '… ' + kept
134}
135
136export type ResumeMark = { cell: number; cost: string }
137export type PlacedMark = { cell: number; cost: string | null }
138export type PlacedMarks = { marks: PlacedMark[]; list: { at: number; text: string } | null }
139
140// The free cells between a list of costs and the nearest item of its row
141const LIST_GAP = 2
142
143// Places the resume marks in a row of `width` cells; the last cell stays free, as in cell().
144// A label is the mark, a space and the cost. It stays whole and needs one free cell before the next mark, or before the end of the row.
145// A mark with no room for its label shows only the mark, and its cost goes to a list of costs in time order.
146// The list starts after the last item of the row. When it does not fit there, it ends before the first mark without a label, after the item before that mark.
147// When it fits in neither place, it takes the larger place, and fitList keeps the newest costs behind an ellipsis.
148export function placeMarks(marks: ResumeMark[], width: number): PlacedMarks {
149 const length = (text: string) => Array.from(text).length
150 const itemEnd = (mark: PlacedMark) => mark.cell + (mark.cost === null ? 1 : 2 + length(mark.cost))
151 const sorted = [...marks].sort((a, b) => a.cell - b.cell)
152 const placed: PlacedMark[] = []
153 const unlabelled: ResumeMark[] = []
154 sorted.forEach((mark, i) => {
155 const next = i + 1 < sorted.length ? sorted[i + 1].cell : width
156 // Two resumes in one cell share its mark: the later one has the label
157 const isFit = next !== mark.cell && mark.cell + 2 + length(mark.cost) < next
158 if (next !== mark.cell) placed.push({ cell: mark.cell, cost: isFit ? mark.cost : null })
159 if (!isFit) unlabelled.push(mark)
160 })
161 if (unlabelled.length === 0) return { marks: placed, list: null }
162 const costs = unlabelled.map((m) => m.cost)
163 const all = costs.join(', ')
164 const after = itemEnd(placed[placed.length - 1]) + LIST_GAP
165 const roomAfter = width - 1 - after
166 if (length(all) <= roomAfter) return { marks: placed, list: { at: after, text: all } }
167 const anchor = unlabelled[0].cell
168 const earlier = placed.filter((m) => m.cell < anchor).pop()
169 const roomBefore = anchor - LIST_GAP - (earlier === undefined ? 0 : itemEnd(earlier) + LIST_GAP)
170 const isBefore = length(all) <= roomBefore || roomBefore > roomAfter
171 const room = isBefore ? roomBefore : roomAfter
172 if (room < 1) return { marks: placed, list: null }
173 const text = fitList(costs, room)
174 return { marks: placed, list: { at: isBefore ? anchor - LIST_GAP - length(text) : after, text } }
175}
176
177export function line(cells: string[], columns: Column[]): string {
178 return columns.map((column, i) => cell(cells[i] ?? '', column)).join('')
179}
180
181export function ageText(ms: number): string {
182 const minutes = Math.floor(ms / 60_000)
183 return minutes < 60 ? minutes + 'm' : Math.floor(minutes / 60) + 'h'
184}
185
186function rank(kind: string): number {
187 const i = LIMIT_ORDER.indexOf(kind)
188 return i === -1 ? LIMIT_ORDER.length : i
189}
190
191// The age of the last limit reading, when it is older than 30 minutes: `(2h ago)`. Empty when the reading is recent or unknown
192export function limitsAgeText(limitsAt: number | null, now: number): string {
193 return limitsAt !== null && now - limitsAt > 30 * 60_000 ? '(' + ageText(now - limitsAt) + ' ago)' : ''
194}
195
196// A limit in the band: its kind, its text (`week 49%`) and its projection (` → 100% Sat 21:06`, or empty)
197export type LimitItem = { kind: string; text: string; projection: string }
198
199// The limits in the order week, 5h, spend. readAt is the time of the reading, or null for no projections
200export function limitItems(limits: Limit[], readAt: number | null, now: number): LimitItem[] {
201 return [...limits]
202 .sort((a, b) => rank(a.kind) - rank(b.kind))
203 .map((limit) => ({ kind: limit.kind, text: (LIMIT_LABELS[limit.kind] ?? limit.kind) + ' ' + formatPercent(limit.percentUsed), projection: limitProjection(limit, readAt, now) }))
204}
205
206// The time when a limit reaches 100%, at the pace from the start of its window up to the reading. Null without a pace.
207// The pace ends at the reading and not at now: an old reading would give a time that is too late
208export function fullAt(percent: number, start: number, readAt: number): number | null {
209 if (!Number.isFinite(percent) || percent <= 0 || readAt <= start) return null
210 return start + ((readAt - start) * 100) / percent
211}
212
213// The projection of a limit in the band: ` → 100% Sat 21:06`. It is empty for a limit without a window or a reset time,
214// when the limit reaches 100% at or after its reset, and when the time has passed: an old reading, or a limit at 100%
215export function limitProjection(limit: Limit, readAt: number | null, now: number): string {
216 const window = LIMIT_WINDOWS[limit.kind]
217 const resetAt = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
218 if (window === undefined || readAt === null || !Number.isFinite(resetAt)) return ''
219 const at = fullAt(limit.percentUsed, resetAt - window, readAt)
220 return at !== null && at < resetAt && at > now ? ' → 100% ' + dayTime(at) : ''
221}
222
223// The label of the tube in three parts, so that the band can leave out the context and the price: the minutes (`47m left`, `15m`, `in turn`),
224// the context (` · 412k cached`, ` · next message re-writes 412k`) and the price (` · $3.30 to re-warm`, ` ≈ $3.30`).
225// rewarm is the cost to write the context again, or null for a model without a price. isEstimated marks a cost from a fallback price with `≈`.
226// ttl is the cache life of the last request. Only COLD and LIVE show without it: the other stages need a known life
227export type TubeParts = { lead: string; context: string; price: string }
228
229export function tubeParts(stage: Stage, lastAt: number | null, now: number, contextTokens: number, rewarm: number | null, isEstimated: boolean, ttl: Ttl | null): TubeParts {
230 const cached = formatTokens(contextTokens)
231 if (stage === 'LIVE' || lastAt === null) return { lead: 'in turn', context: contextTokens > 0 ? ' · ' + cached + ' cached' : '', price: '' }
232 if (stage === 'COLD' || ttl === null) return { lead: minutesCold(lastAt, now, ttl) + 'm', context: ' · next message re-writes ' + cached, price: rewarm === null ? '' : ' ≈ ' + formatMoney(rewarm) }
233 return { lead: minutesLeft(lastAt, now, ttl) + 'm left', context: ' · ' + cached + ' cached', price: rewarm === null ? '' : ' · ' + (isEstimated ? '≈ ' : '') + formatMoney(rewarm) + ' to re-warm' }
234}
235
236export const UNKNOWN_LIFE = 'cache life unknown'
237
238// The band label before the mod knows the cache life: the time since the last request, and no countdown
239export function unknownLabel(lastAt: number, now: number): string {
240 return UNKNOWN_LIFE + ' · last request ' + Math.max(0, Math.floor((now - lastAt) / 60_000)) + 'm ago'
241}
242
243export function dayTime(ms: number): string {
244 return DAYS[new Date(ms).getDay()] + ' ' + clockTime(ms)
245}
246
247// The local time of day: `14:32`
248export function clockTime(ms: number): string {
249 const d = new Date(ms)
250 return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
251}
252
253// The projection of the Week tab. readAt is the time of the weekly reading, or null without a reading.
254// A time that has passed is left out, as in the band
255export function projectionText(percent: number | null, start: number, readAt: number | null, resetAt: number | null, now: number): string {
256 if (percent === null || readAt === null || resetAt === null) return ''
257 const at = fullAt(percent, start, readAt)
258 if (at === null || at >= resetAt) return 'below 100% at reset'
259 return at > now ? '100% on ' + dayTime(at) : ''
260}
261
262// Always the 14 periods of the week: a period that starts at or after now is a future cell, a past period without a reading is an empty cell
263export function historyCells(readings: Reading[], start: number, now: number): HistoryCell[] {
264 const weekly = readings.filter((r) => r.kind === 'seven_day' && Number.isFinite(r.percentUsed))
265 return Array.from({ length: HISTORY_CELLS }, (_, i): HistoryCell => {
266 const from = start + i * HISTORY_MS
267 if (from >= now) return { char: ' ', percent: null, isFuture: true }
268 const inPeriod = weekly.filter((r) => r.at >= from && r.at < from + HISTORY_MS)
269 if (inPeriod.length === 0) return { char: '░', percent: null, isFuture: false }
270 const top = Math.max(...inPeriod.map((r) => r.percentUsed))
271 return { char: SPARK[Math.min(7, Math.max(0, Math.floor((top / 100) * 8)))], percent: top, isFuture: false }
272 })
273}
274
275// The first letter of each day of the week and a space, `S ` to `S `, so the names stand apart. Day i is the weekday of start + i * 24 h in local time, and it holds 2 cells of the history
276export function weekDayNames(start: number): string[] {
277 return Array.from({ length: HISTORY_CELLS / 2 }, (_, i) => DAYS[new Date(start + i * 24 * 3_600_000).getDay()].slice(0, 1) + ' ')
278}
279hooks/prices.ts 141 lines1import type { Ttl } from '../types'
2
3export type Rates = { input: number; write5m: number; write1h: number; read: number; output: number }
4
5// above: the rates of a request whose prompt (input, cache read and cache write) has more than `tokens` tokens
6export type Price = Rates & { above?: { tokens: number; rates: Rates } }
7
8// USD per million tokens. Source: https://platform.claude.com/docs/en/about-claude/pricing,
9// read 2026-10-08. The match of the cache lifetime (matchLifetime) needs these prices to the cent: Claude Code books the same.
10export const PRICES: Record<string, Price> = {
11 'claude-fable-5-1': { input: 10, write5m: 12.5, write1h: 20, read: 0.25, output: 50 },
12 'claude-fable-5': { input: 10, write5m: 12.5, write1h: 20, read: 1, output: 50 },
13 'claude-opus-5-5': { input: 4, write5m: 5, write1h: 8, read: 0.2, output: 20 },
14 'claude-opus-5': { input: 5, write5m: 6.25, write1h: 10, read: 0.5, output: 25 },
15 'claude-opus-4-8': { input: 5, write5m: 6.25, write1h: 10, read: 0.5, output: 25 },
16 'claude-sonnet-5-5': { input: 2, write5m: 2.5, write1h: 4, read: 0.1, output: 10 },
17 'claude-sonnet-5': { input: 2, write5m: 2.5, write1h: 4, read: 0.2, output: 10 },
18 'claude-haiku-5-5': { input: 0.1, write5m: 0.125, write1h: 0.2, read: 0.01, output: 0.5, above: { tokens: 100_000, rates: { input: 0.5, write5m: 0.625, write1h: 1, read: 0.05, output: 2.5 } } },
19 'claude-haiku-4-5': { input: 1, write5m: 1.25, write1h: 2, read: 0.1, output: 5 },
20}
21
22export type UsageLike = {
23 model: string
24 input_tokens?: number | null
25 output_tokens?: number | null
26 cache_read_input_tokens?: number | null
27 cache_creation_input_tokens?: number | null
28}
29
30export function tokens(value: unknown): number {
31 return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
32}
33
34// The price source of a model: `exact` when the table has the model, `fallback` when the price is the one of the newest model of the same family
35export type PriceInfo = { price: Price; source: 'exact' | 'fallback'; from?: string }
36
37// The id without a context suffix (`[1m]`) and without a date suffix (`-20251001`)
38export function baseModel(model: string): string {
39 return model.replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
40}
41
42// The family of a model id is the word after `claude-`: `opus` for `claude-opus-5-5`. An id of another form has none
43export function familyOf(model: string): string | undefined {
44 return /^claude-([^-]+)/.exec(model)?.[1]
45}
46
47// The version of a model id is the numbers after the family: `claude-opus-5-5` is [5, 5]
48export function versionOf(model: string): number[] {
49 return model
50 .replace(/^claude-[^-]+-?/, '')
51 .split('-')
52 .filter((part) => part !== '')
53 .map((part) => parseInt(part, 10) || 0)
54}
55
56// Compares two versions number by number. A missing part counts as 0, so [5] equals [5, 0]. Returns a number below 0, 0 or above 0
57export function compareVersions(a: number[], b: number[]): number {
58 for (let i = 0; i < Math.max(a.length, b.length); i++) {
59 const diff = (a[i] ?? 0) - (b[i] ?? 0)
60 if (diff !== 0) return diff
61 }
62 return 0
63}
64
65// The key of the newest model of a family in the table, or undefined when the family has none
66export function newestOf(family: string): string | undefined {
67 let newest: string | undefined
68 for (const key of Object.keys(PRICES)) {
69 if (familyOf(key) !== family) continue
70 if (newest === undefined || compareVersions(versionOf(key), versionOf(newest)) > 0) newest = key
71 }
72 return newest
73}
74
75export function priceInfo(model: string): PriceInfo | undefined {
76 const base = baseModel(model)
77 const exact = Object.prototype.hasOwnProperty.call(PRICES, base) ? PRICES[base] : undefined
78 if (exact !== undefined) return { price: exact, source: 'exact' }
79 const family = familyOf(base)
80 const from = family === undefined ? undefined : newestOf(family)
81 const price = from === undefined ? undefined : PRICES[from]
82 return from === undefined || price === undefined ? undefined : { price, source: 'fallback', from }
83}
84
85// The exact price, or the fallback price of the newest model of the family
86export function priceOf(model: string): Price | undefined {
87 return priceInfo(model)?.price
88}
89
90// The rates of a request with this many prompt tokens
91export function ratesOf(price: Price, promptTokens: number): Rates {
92 return price.above !== undefined && promptTokens > price.above.tokens ? price.above.rates : price
93}
94
95// The prompt of a request: input, cache read and cache write
96export function promptOf(usage: UsageLike): number {
97 return tokens(usage.input_tokens) + tokens(usage.cache_read_input_tokens) + tokens(usage.cache_creation_input_tokens)
98}
99
100// The cost of a request whose cache writes have the lifetime ttl
101export function costOf(usage: UsageLike, ttl: Ttl): number {
102 const price = priceOf(usage.model)
103 if (!price) return 0
104 const rates = ratesOf(price, promptOf(usage))
105 return (
106 (tokens(usage.input_tokens) * rates.input +
107 tokens(usage.cache_creation_input_tokens) * (ttl === '5m' ? rates.write5m : rates.write1h) +
108 tokens(usage.cache_read_input_tokens) * rates.read +
109 tokens(usage.output_tokens) * rates.output) /
110 1e6
111 )
112}
113
114// The cost of the cache writes of a request
115export function writeCostOf(usage: UsageLike, ttl: Ttl): number {
116 const price = priceOf(usage.model)
117 if (!price) return 0
118 const rates = ratesOf(price, promptOf(usage))
119 return (tokens(usage.cache_creation_input_tokens) * (ttl === '5m' ? rates.write5m : rates.write1h)) / 1e6
120}
121
122// What the next message costs to write the whole context again
123export function rewarmCost(model: string, contextTokens: number, ttl: Ttl): number {
124 return writeCostOf({ model, cache_creation_input_tokens: contextTokens }, ttl)
125}
126
127// Far below the smallest gap between the two prices of one request (a 1-token write of Haiku 5.5: 0.075 per million),
128// and far above the rounding of the session cost (about 1e-13 for a session of 1000 USD)
129const MATCH_TOLERANCE = 1e-9
130
131// The lifetime whose price gives the cost that Claude Code booked for the request, or null.
132// Claude Code books each request at the price of its real lifetime, so exactly one lifetime fits when booked holds this request alone.
133// null when the request wrote nothing, when the model has no exact price (a fallback price is no evidence), and when none or both fit:
134// a higher price (fast mode, US-only inference, a price of the organization) or another booking in the same reading
135export function matchLifetime(usage: UsageLike, booked: number | null): Ttl | null {
136 if (booked === null || !Number.isFinite(booked) || tokens(usage.cache_creation_input_tokens) === 0) return null
137 if (priceInfo(usage.model)?.source !== 'exact') return null
138 const fits = (['5m', '1h'] as const).filter((ttl) => Math.abs(costOf(usage, ttl) - booked) <= MATCH_TOLERANCE)
139 return fits.length === 1 ? fits[0] : null
140}
141hooks/recommend.ts 263 lines1import type { Breakdown, Causes, Limit, MainRequest, Resume, Totals, Ttl } from '../types'
2import { dayTime, formatMoney, formatPercent, formatTokens, limitItems, limitsAgeText, shortModel } from './format'
3import { PRICES, familyOf, newestOf, priceInfo, ratesOf, type PriceInfo, type Rates } from './prices'
4import { stripCells } from './temperature'
5import { STRIP_MS, rowsOf, sumAll } from './tally'
6import type { WeekData } from './view'
7
8// The scope of the call in the totals: the Session and the Week tab show its cost under this name
9export const RECOMMEND_SCOPE = 'recommend'
10// The model of the call when the userConfig option is empty. The alias resolves like --model, so it follows each new Sonnet release
11export const DEFAULT_MODEL = 'sonnet'
12// The token cap of the reply. The highest cost counts the full cap
13export const OUTPUT_CAP = 4000
14// The input estimate: 1 token for every 3 characters of the prompt and the system prompt. Data text with many numbers has short tokens
15export const CHARS_PER_TOKEN = 3
16// The longest wait for the reply
17export const RECOMMEND_TIMEOUT_MS = 120_000
18// How hard the model thinks. Thinking tokens count in the output cap, so a high effort could leave no room for the reply
19export const RECOMMEND_EFFORT = 'medium'
20// The most rows of each Week table in the prompt
21const WEEK_ROWS = 10
22// A Markdown or a Text holds at most 10000 characters, and tab and newline are its only control characters
23const MAX_TEXT = 10_000
24const CUT_NOTE = '\n\n… (cut at 10,000 characters)'
25
26export const RECOMMEND_SYSTEM = [
27 'You give advice on the Claude Code usage of one person. The data comes from token-watch, a Claude Code mod that counts the tokens of the sessions on this computer. The data holds token counts, costs, plan limits and the names of repos, memory files, MCP servers and agents. It holds no conversation text.',
28 '',
29 'Give at most 5 recommendations that lower the cost and the use of the plan allowance. Use only these levers, which the person controls:',
30 '- Resume or new session. A message after a pause longer than the cache life writes the whole context to the cache again: a resume. A new session starts with a small context.',
31 '- Memory files and MCP servers. Their tokens go into every request.',
32 '- Model choice. A smaller model has a lower price for each token.',
33 '- Subagents. A subagent works in a context of its own, and its cache expires after 5 minutes.',
34 '',
35 'Rules:',
36 '- Base each recommendation on figures in the data, and name them.',
37 '- Give the expected saving as a figure when the data allows it.',
38 '- When the data shows no problem for a lever, give no recommendation for it.',
39 '- Every cost is an estimate at API list prices. On a subscription the plan allowance counts, not the dollars: use the costs to compare.',
40 '- Write Markdown: a heading for each recommendation, then at most three sentences. Put the largest saving first. No table. At most 300 words.',
41].join('\n')
42
43export type RecommendInput = {
44 now: number
45 totals: Totals
46 causes: Causes
47 // The cost that Claude Code reports with /cost, or null
48 usd: number | null
49 requests: MainRequest[]
50 // The cache life of the main conversation, or null when the mod does not know it
51 mainTtl: Ttl | null
52 resumes: Resume[]
53 limits: Limit[]
54 limitsAt: number | null
55 week: WeekData
56 breakdown: Breakdown | null
57}
58
59// The four token counts of a model call, as the API spells them
60export type CallUsage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
61
62// The result of the call when it gave no reply: the reason, and for an API error its kind and HTTP status
63export type CallFailure = { reason?: string; status?: number | null; error?: string }
64
65// The model of the call: the userConfig option `recommendModel`, or the default when it is empty.
66// The option is free text. An alias and a `claude-` id are lower case, so `Sonnet` counts as `sonnet`.
67// Another id stays as typed, because the id of a provider can depend on case (an Amazon Bedrock ARN)
68export function modelOption(options: unknown): string {
69 const value = typeof options === 'object' && options !== null ? (options as Record<string, unknown>).recommendModel : undefined
70 if (typeof value !== 'string' || value.trim() === '') return DEFAULT_MODEL
71 const typed = value.trim()
72 const lower = typed.toLowerCase()
73 return /^[a-z]+(\[1m\])?$/.test(lower) || lower.startsWith('claude-') ? lower : typed
74}
75
76// Why the call gave no reply, as one sentence for the dialog
77export function failureText(r: CallFailure): string {
78 if (r.reason === 'api-error') return 'The API answered with an error: ' + (r.error ?? 'unknown') + (typeof r.status === 'number' ? ' (HTTP ' + r.status + ').' : ' (no response).')
79 if (r.reason === 'empty-reply') return 'The model sent a reply without text.'
80 if (r.reason === 'aborted') return 'The call stopped before the reply: it was cancelled, or no reply came within 2 minutes.'
81 return 'The call gave no reply.'
82}
83
84// A text that the dialog can draw: carriage returns and other control characters out, and at most 10000 characters.
85// A longer reply is cut, with a note, because the engine refuses a tree with a longer text and closes the pane
86export function drawableText(text: string): string {
87 const clean = text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
88 if (clean.length <= MAX_TEXT) return clean
89 let cut = ''
90 for (const char of Array.from(clean)) {
91 if (cut.length + char.length > MAX_TEXT - CUT_NOTE.length) break
92 cut += char
93 }
94 return cut + CUT_NOTE
95}
96
97// The id that prices the call and that the totals use. An alias has no version, so `sonnet` becomes `claude-sonnet`:
98// the price is the one of the newest Sonnet in the table, and the tabs mark the cost with ≈
99export function priceModelOf(model: string): string {
100 const id = model.trim()
101 return id.startsWith('claude-') ? id : 'claude-' + id
102}
103
104// The key of the table that gives the price, for the dialog: `sonnet-5-5`, or undefined for a model without a price
105export function priceSourceOf(priceModel: string): string | undefined {
106 const info = priceInfo(priceModel)
107 if (info === undefined) return undefined
108 return shortModel(info.source === 'fallback' && info.from !== undefined ? info.from : priceModel)
109}
110
111export function estimateTokens(text: string): number {
112 return Math.ceil(Array.from(text).length / CHARS_PER_TOKEN)
113}
114
115// The highest cost at API prices: the input estimate and the full output cap. Null for a model without a price
116export function maxCostOf(info: PriceInfo | undefined, inputTokens: number, outputCap: number): number | null {
117 if (info === undefined) return null
118 const rates = ratesOf(info.price, inputTokens)
119 return (inputTokens * rates.input + outputCap * rates.output) / 1e6
120}
121
122function percentOf(part: number, whole: number): string {
123 return whole > 0 ? formatPercent(Math.round((part / whole) * 100)) : '0%'
124}
125
126// `25 min ago`, `3 h 12 min ago`
127export function agoText(ms: number): string {
128 const minutes = Math.max(0, Math.round(ms / 60_000))
129 if (minutes < 60) return minutes + ' min ago'
130 const rest = minutes % 60
131 return Math.floor(minutes / 60) + ' h' + (rest === 0 ? '' : ' ' + rest + ' min') + ' ago'
132}
133
134function limitLines(d: RecommendInput): string[] {
135 const items = limitItems(d.limits, d.limitsAt, d.now)
136 if (items.length === 0) return ['No limit reading.']
137 // One reading gives all limits, so its age shows once: `(3h ago)` becomes `Last reading: 3h ago.`
138 const age = limitsAgeText(d.limitsAt, d.now)
139 const lines = items.map((item) => {
140 const limit = d.limits.find((l) => l.kind === item.kind)
141 const reset = limit?.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
142 const parts = [item.text + ' used']
143 if (Number.isFinite(reset)) parts.push('resets ' + dayTime(reset))
144 if (item.projection !== '') parts.push('100% at the current pace on ' + item.projection.replace(' → 100% ', ''))
145 return '- ' + parts.join(', ')
146 })
147 return age === '' ? lines : [...lines, 'Last reading: ' + age.slice(1, -1) + '.']
148}
149
150function costText(model: string, cost: number): string {
151 const info = priceInfo(model)
152 if (info === undefined) return 'no price'
153 return formatMoney(cost) + (info.source === 'fallback' ? ' (price of ' + shortModel(info.from ?? model) + ')' : '')
154}
155
156function sessionLines(d: RecommendInput): string[] {
157 const rows = rowsOf(d.totals)
158 if (rows.length === 0) return ['No model request in this conversation yet.']
159 const total = sumAll(d.totals)
160 const lines = rows.map((r) => {
161 const c = r.counts
162 return '- ' + shortModel(r.model) + ' ' + r.scope + ': ' + c.requests + (c.requests === 1 ? ' request' : ' requests') + ', input ' + formatTokens(c.input) + ', cache write ' + formatTokens(c.cacheWrite) + ', cache read ' + formatTokens(c.cacheRead) + ', output ' + formatTokens(c.output) + ', ' + costText(r.model, c.cost) + ' (' + percentOf(c.cost, total.cost) + ')'
163 })
164 lines.push('Total: ' + formatMoney(total.cost) + '.' + (d.usd === null ? '' : ' /cost reports ' + formatMoney(d.usd) + '. /cost also counts requests that token-watch does not see, for example compaction.'))
165 return lines
166}
167
168function causeLines(causes: Causes): string[] {
169 const whole = causes.start.cost + causes.growth.cost + causes.resume.cost
170 const line = (name: string, what: string, c: { tokens: number; cost: number }) => '- ' + name + ' (' + what + '): ' + formatTokens(c.tokens) + ' tokens, ' + formatMoney(c.cost) + ', ' + percentOf(c.cost, whole)
171 return [
172 line('start', 'the first request of a thread', causes.start),
173 line('growth', 'new context in a running thread', causes.growth),
174 line('resume', 'after a pause longer than the cache life', causes.resume),
175 ]
176}
177
178// The strip of the Session tab as letters: W warm, c cold, . no request yet
179function stripText(requests: MainRequest[], now: number): string {
180 return stripCells(requests, now)
181 .map((c) => (c.color === '' ? '.' : c.char === '█' ? 'W' : 'c'))
182 .join('')
183}
184
185function historyLines(d: RecommendInput): string[] {
186 const recent = d.requests.filter((r) => r.at > d.now - STRIP_MS)
187 const resumes = d.resumes.filter((r) => r.at > d.now - STRIP_MS)
188 return [
189 'Main requests in the last 4 hours: ' + recent.length + '.',
190 'Cache state for each 5 minutes of the last 4 hours, oldest first (W warm, c cold, . before the first request):',
191 stripText(d.requests, d.now),
192 resumes.length === 0 ? 'Resumes in the last 4 hours: none.' : 'Resumes in the last 4 hours: ' + resumes.map((r) => agoText(d.now - r.at) + ', cache write ' + formatMoney(r.cost)).join('; ') + '.',
193 ]
194}
195
196function contextLines(b: Breakdown | null): string[] {
197 if (b === null) return ['Not available.']
198 const list = (title: string, rows: { name: string; tokens: number }[]) => (rows.length === 0 ? [] : [title + ': ' + rows.map((r) => r.name + ' ' + formatTokens(r.tokens) + ' (' + percentOf(r.tokens, b.total) + ')').join(', ')])
199 return [
200 'Context: ' + formatTokens(b.total) + (b.max > 0 ? ' of ' + formatTokens(b.max) + ' (' + percentOf(b.total, b.max) + ')' : ''),
201 ...b.categories.map((c) => '- ' + c.name + ' ' + formatTokens(c.tokens) + ' (' + percentOf(c.tokens, b.total) + ')'),
202 ...list('Largest memory files', b.memoryFiles),
203 ...list('MCP servers', b.mcpServers),
204 ...list('Custom agents', b.agents),
205 ]
206}
207
208function weekLines(w: WeekData): string[] {
209 const share = (rows: { name: string; cost: number; isUnpriced?: boolean }[]) =>
210 rows.length === 0
211 ? 'none'
212 : rows
213 .slice(0, WEEK_ROWS)
214 .map((r) => r.name + ' ' + (r.isUnpriced ? 'no price' : formatMoney(r.cost) + ' (' + percentOf(r.cost, w.total) + ')'))
215 .join(', ')
216 const periods = w.history.filter((h) => !h.isFuture).map((h) => (h.percent === null ? 'no reading' : formatPercent(Math.round(h.percent))))
217 return [
218 'Week since ' + dayTime(w.start) + ', all sessions on this computer that run token-watch.',
219 w.percent === null ? 'Weekly limit: no reading.' : 'Weekly limit: ' + formatPercent(w.percent) + ' used' + (w.resetAt === null ? '' : ', resets ' + dayTime(w.resetAt)) + (w.projection === '' ? '' : ', at the current rate ' + w.projection) + '.',
220 ...(periods.length === 0 ? [] : ['Highest weekly percent of each 12 hours, oldest first: ' + periods.join(', ') + '.']),
221 'Cost by repo: ' + share(w.byRepo) + '.',
222 'Cost by model and scope: ' + share(w.byModelScope) + '.',
223 ]
224}
225
226function ratesText(r: Rates): string {
227 return 'input ' + r.input + ', cache write ' + r.write1h + ' (1 hour) or ' + r.write5m + ' (5 minutes), cache read ' + r.read + ', output ' + r.output
228}
229
230function lifeText(ttl: Ttl | null): string {
231 return ttl === null ? 'unknown' : ttl === '1h' ? '1 hour' : '5 minutes'
232}
233
234// The prices of the newest model of each family in the table, so that the model can put a figure on a change of the model
235function priceLines(): string[] {
236 const families = [...new Set(Object.keys(PRICES).flatMap((key) => familyOf(key) ?? []))]
237 return families.flatMap((family) => {
238 const key = newestOf(family)
239 if (key === undefined) return []
240 const p = PRICES[key]
241 const above = p.above === undefined ? '' : '; for a prompt above ' + formatTokens(p.above.tokens) + ' tokens: ' + ratesText(p.above.rates)
242 return ['- ' + shortModel(key) + ': ' + ratesText(p) + above]
243 })
244}
245
246// The user message of the call: the data that the tabs show, as text. No transcript text, no file content, no prompt text
247export function recommendPrompt(d: RecommendInput): string {
248 const section = (title: string, lines: string[]) => ['## ' + title, ...lines, '']
249 return [
250 'token-watch data, read ' + dayTime(d.now) + ' (local time). Costs are estimates at API list prices.',
251 '',
252 ...section('Plan limits', limitLines(d)),
253 ...section('This conversation, by model and scope', sessionLines(d)),
254 ...section('Cache writes of this conversation, by cause', [...causeLines(d.causes), 'Cache life of the main conversation: ' + lifeText(d.mainTtl) + '. Subagents: 5 minutes, unless a setting gives them 1 hour.']),
255 ...section('Cache history of this conversation', historyLines(d)),
256 ...section('Context of this conversation', contextLines(d.breakdown)),
257 ...section('This week', weekLines(d.week)),
258 ...section('API list prices in USD per million tokens', priceLines()),
259 ]
260 .join('\n')
261 .trimEnd()
262}
263hooks/temperature.ts 225 lines1import type { Lifetime, MainRequest, Ttl } from '../types'
2
3export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4const LONGEST_TTL_MS = TTL_MS['1h']
5
6// Before the mod knows a lifetime it prices with the default of Claude Code: 1 hour for the main conversation on a subscription, 5 minutes for a subagent
7export function defaultTtl(isSubagent: boolean): Ttl {
8 return isSubagent ? '5m' : '1h'
9}
10
11export const NO_LIFETIME: Lifetime = { known: null, pending: null }
12
13// The first match sets the lifetime. A different lifetime needs two matches in a row, so one booking that fits by chance changes nothing.
14// A request without a match (null) leaves the lifetime and the pending match as they are
15export function confirmLifetime(l: Lifetime, match: Ttl | null): Lifetime {
16 if (match === null) return l
17 if (l.known === null) return { known: match, pending: null }
18 if (match === l.known) return l.pending === null ? l : { ...l, pending: null }
19 return l.pending === match ? { known: match, pending: null } : { ...l, pending: match }
20}
21
22// The lifetime that a resumed conversation proves: Claude Code says whether the time since the last response is longer than the lifetime.
23// Warm after more than 5 minutes proves 1 hour. Expired after more than 5 minutes and within 1 hour proves 5 minutes.
24// Else both lives fit, or none does (expired within 5 minutes), and the result is null
25export function ttlFromResume(seconds: number, isExpired: unknown): Ttl | null {
26 const ms = seconds * 1000
27 if (isExpired === false && ms > TTL_MS['5m']) return '1h'
28 if (isExpired === true && ms > TTL_MS['5m'] && ms <= TTL_MS['1h']) return '5m'
29 return null
30}
31
32const STRIP_CELLS = 48
33const STRIP_CELL_MS = 5 * 60_000
34
35export type Stage = 'LIVE' | 'HOT' | 'WARM' | 'COOLING' | 'COLD'
36export type ColorMode = 'hex' | 'named'
37export const COLOR_MODE: ColorMode = 'hex'
38
39export type Cell = { char: string; color: string; isEmpty: boolean }
40export type StripCell = { char: string; color: string }
41
42const STOPS: [number, number[]][] = [
43 [0, [0x37, 0x8a, 0xdd]],
44 [0.33, [0x1d, 0x9e, 0x75]],
45 [0.66, [0xef, 0x9f, 0x27]],
46 [1, [0xe2, 0x4b, 0x4a]],
47]
48const EIGHTHS = '▏▎▍▌▋▊▉'
49
50function clamp(x: number): number {
51 return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : 0
52}
53
54// The part of the lifetime that is left, or null without a request. An unknown lifetime (ttl null) gives null, so the tube shows no countdown,
55// until the longest lifetime has passed: then the cache is cold for both lifetimes
56export function fraction(lastAt: number | null, now: number, isWorking: boolean, ttl: Ttl | null): number | null {
57 if (isWorking) return 1
58 if (lastAt === null) return null
59 if (ttl === null) return now - lastAt > LONGEST_TTL_MS ? 0 : null
60 return clamp(1 - (now - lastAt) / TTL_MS[ttl])
61}
62
63export function stageOf(f: number, isWorking: boolean): Stage {
64 if (isWorking) return 'LIVE'
65 if (f >= 0.66) return 'HOT'
66 if (f >= 0.33) return 'WARM'
67 if (f > 0) return 'COOLING'
68 return 'COLD'
69}
70
71export function minutesLeft(lastAt: number, now: number, ttl: Ttl): number {
72 return Math.min(TTL_MS[ttl] / 60_000, Math.max(0, Math.ceil((lastAt + TTL_MS[ttl] - now) / 60_000)))
73}
74
75// The minutes since the cache expired. An unknown lifetime counts from the end of the longest one, the time from which the cache is cold for sure
76export function minutesCold(lastAt: number, now: number, ttl: Ttl | null): number {
77 return Math.max(0, Math.floor((now - lastAt - (ttl === null ? LONGEST_TTL_MS : TTL_MS[ttl])) / 60_000))
78}
79
80function hex(n: number): string {
81 return n.toString(16).padStart(2, '0')
82}
83
84export function heat(x: number, mode: ColorMode = COLOR_MODE): string {
85 const v = clamp(x)
86 if (mode === 'named') return v < 0.25 ? 'blue' : v < 0.5 ? 'cyan' : v < 0.75 ? 'yellow' : 'red'
87 for (let i = 1; i < STOPS.length; i++) {
88 const [from, low] = STOPS[i - 1]
89 const [to, high] = STOPS[i]
90 if (v <= to) {
91 const t = (v - from) / (to - from)
92 return '#' + low.map((c, k) => hex(Math.round(c + (high[k] - c) * t))).join('')
93 }
94 }
95 return '#e24b4a'
96}
97
98// Text colours. The mod cannot read the theme of Claude Code, so one palette has to read on a white and on a dark background.
99// The relative luminance (WCAG) of a text colour stays between 0.14 and 0.30: the contrast is at least 3:1 against #ffffff and against #1e1e1e.
100// A colour out of the range is scaled to this much inside it, so that the rounding to 8 bits cannot leave the range
101const TEXT_MIN_LUMINANCE = 0.14
102const TEXT_MAX_LUMINANCE = 0.3
103const TEXT_MARGIN = 0.005
104
105const toLinear = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4)
106const toChannel = (l: number) => Math.round(255 * Math.min(1, Math.max(0, l <= 0.0031308 ? l * 12.92 : 1.055 * l ** (1 / 2.4) - 0.055)))
107
108// The relative luminance of linear RGB values
109const luminanceOf = ([r, g, b]: number[]) => 0.2126 * r + 0.7152 * g + 0.0722 * b
110
111// The colour of heat(x) for text: the same hue, scaled in linear RGB into the luminance range. Use it for each word and number in a heat colour.
112// Use heat(x) for graphics: the cells of a tube, a bar, the strip and the week history, and the SVG documents.
113// The named colours follow the theme of the terminal, so they stay as they are
114export function heatText(x: number, mode: ColorMode = COLOR_MODE): string {
115 const color = heat(x, mode)
116 if (mode === 'named') return color
117 const linear = [1, 3, 5].map((i) => toLinear(parseInt(color.slice(i, i + 2), 16)))
118 const luminance = luminanceOf(linear)
119 if (luminance >= TEXT_MIN_LUMINANCE && luminance <= TEXT_MAX_LUMINANCE) return color
120 const target = luminance > TEXT_MAX_LUMINANCE ? TEXT_MAX_LUMINANCE - TEXT_MARGIN : TEXT_MIN_LUMINANCE + TEXT_MARGIN
121 return '#' + linear.map((l) => hex(toChannel((l * target) / luminance))).join('')
122}
123
124// The tube runs from cold at the left to hot at the right and empties from the hot end
125export function tubeCells(f: number, n: number, mode: ColorMode = COLOR_MODE): Cell[] {
126 const x = cellsOf(clamp(f), n)
127 const full = Math.floor(x)
128 const eighths = Math.floor((x - full) * 8)
129 const cells: Cell[] = []
130 for (let i = 0; i < n; i++) {
131 const color = heat((i + 0.5) / n, mode)
132 if (i < full) cells.push({ char: '█', color, isEmpty: false })
133 else if (i === full && eighths >= 1) cells.push({ char: EIGHTHS[eighths - 1], color, isEmpty: false })
134 else cells.push({ char: '░', color, isEmpty: true })
135 }
136 return cells
137}
138
139// The filled length of a bar in cells. Rounding to 6 decimals keeps float noise from showing a full cell as a partial one
140function cellsOf(v: number, n: number): number {
141 return Math.round(v * n * 1e6) / 1e6
142}
143
144const num = (x: number) => String(Number(x.toFixed(2)))
145
146function svgDoc(width: number, height: number, parts: string): string {
147 return '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '" viewBox="0 0 ' + width + ' ' + height + '">' + parts + '</svg>'
148}
149
150function backRect(x: number, color: string): string {
151 return '<rect x="' + x + '" y="1" width="8" height="12" rx="2" fill="' + color + '" opacity="0.18"/>'
152}
153
154// The outline of a cell that has not come yet: the cell rect inset by half a pixel, so that its 1 px stroke stays inside the cell.
155// The grey and its opacity show on a dark and on a light background, so the Svg needs no theme colour
156function outlineRect(x: number): string {
157 return '<rect x="' + (x + 0.5) + '" y="1.5" width="7" height="11" rx="1.5" fill="none" stroke="#8a8a8a" stroke-opacity="0.7" stroke-width="1"/>'
158}
159
160// A row of cells, 9 wide each: a background and a fill that grows from the left. A cell with no colour draws nothing
161function cellRects(x0: number, cells: { color: string; fill: number }[]): string {
162 return cells
163 .map(({ color, fill }, i) => {
164 if (color === '') return ''
165 const x = x0 + i * 9
166 return backRect(x, color) + (fill > 0 ? '<rect x="' + x + '" y="1" width="' + num(8 * fill) + '" height="12" rx="2" fill="' + color + '"/>' : '')
167 })
168 .join('')
169}
170
171// The cells of the tube: x is the filled length in cells
172function tubeParts(x: number, n: number): { color: string; fill: number }[] {
173 return Array.from({ length: n }, (_, i) => ({ color: heat((i + 0.5) / n, 'hex'), fill: clamp(x - i) }))
174}
175
176// The tube and every bar of a share or a percent as an SVG document for the desktop surface: n rounded cells that fill continuously
177export function barSvg(f: number, n: number): string {
178 return svgDoc(n * 9, 14, cellRects(0, tubeParts(cellsOf(clamp(f), n), n)))
179}
180
181// The cache strip: a full cell when warm, a background when cold, nothing without a request
182export function stripSvg(cells: StripCell[]): string {
183 const parts = cellRects(0, cells.map((c) => ({ color: c.color, fill: c.char === '█' ? 1 : 0 })))
184 return svgDoc(cells.length * 9, 14, parts)
185}
186
187// One column for each cell, as high as its percent, at least one eighth. A past cell without a percent draws only a cold background, a future cell draws an outline
188export function sparkSvg(cells: { percent: number | null; isFuture?: boolean }[]): string {
189 const parts = cells.map(({ percent, isFuture }, i) => {
190 const x = i * 9
191 if (percent === null) return isFuture ? outlineRect(x) : backRect(x, heat(0, 'hex'))
192 const color = heat(percent / 100, 'hex')
193 const h = 12 * Math.max(1 / 8, clamp(percent / 100))
194 return backRect(x, color) + '<rect x="' + x + '" y="' + num(13 - h) + '" width="8" height="' + num(h) + '" rx="' + num(Math.min(2, h / 2)) + '" fill="' + color + '"/>'
195 })
196 return svgDoc(cells.length * 9, 14, parts.join(''))
197}
198
199export function tubeAlt(f: number): string {
200 const v = clamp(f)
201 return v <= 0 ? 'cache cold' : 'cache ' + Math.max(1, Math.round(v * 100)) + '% left'
202}
203
204// One cell for each 5 minutes of the last 4 hours, coloured by the temperature at the end of the cell. Each request counts with its own lifetime
205export function stripCells(requests: MainRequest[], now: number, mode: ColorMode = COLOR_MODE): StripCell[] {
206 const cells: StripCell[] = []
207 for (let i = 0; i < STRIP_CELLS; i++) {
208 const end = now - (STRIP_CELLS - 1 - i) * STRIP_CELL_MS
209 const last = requests.filter((r) => r.at <= end).reduce<MainRequest | null>((a, r) => (a === null || r.at >= a.at ? r : a), null)
210 if (last === null) {
211 cells.push({ char: ' ', color: '' })
212 continue
213 }
214 const f = clamp(1 - (end - last.at) / TTL_MS[last.ttl])
215 cells.push(f > 0 ? { char: '█', color: heat(f, mode) } : { char: '░', color: heat(0, mode) })
216 }
217 return cells
218}
219
220// The strip cell that holds a time, or null when the time is outside the strip. Cell i covers (start + i * 5 min, start + (i + 1) * 5 min], as in stripCells
221export function stripCellAt(at: number, now: number): number | null {
222 const i = Math.ceil((at - (now - STRIP_CELLS * STRIP_CELL_MS)) / STRIP_CELL_MS) - 1
223 return i >= 0 && i < STRIP_CELLS ? i : null
224}
225hooks/tally.ts 333 lines1import type { Breakdown, BreakdownRow, Cause, Causes, Counts, Hours, Limit, Main, MainRequest, Reading, Run, Snapshot, Totals, Ttl } from '../types'
2import { shortModel } from './format'
3import { priceInfo, tokens, type UsageLike } from './prices'
4
5export const HOUR_MS = 3_600_000
6export const DAY_MS = 24 * HOUR_MS
7export const KEEP_MS = 8 * DAY_MS
8export const STRIP_MS = 4 * HOUR_MS
9export const MAX_READINGS = 400
10const WORKING_MS = 10 * 60_000
11
12export const EMPTY: Counts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0, cost: 0 }
13export const NO_CAUSES: Causes = { start: { tokens: 0, cost: 0 }, growth: { tokens: 0, cost: 0 }, resume: { tokens: 0, cost: 0 } }
14export const NO_MAIN: Main = { model: '', lastRequestAt: null, ttl: null, contextTokens: 0, isWorking: false, requests: [], resumes: [] }
15
16export type Row = { model: string; scope: string; counts: Counts }
17// isUnpriced: the model has no price. isEstimated: the cost uses the fallback price of the newest model of the family
18export type Share = { name: string; cost: number; isUnpriced?: boolean; isEstimated?: boolean }
19// readAt is the time of the last measure of the weekly reading that gives the percent, or null without a percent
20export type Week = { start: number; resetAt: number | null; percent: number | null; readAt: number | null }
21export type NowRow = {
22 key: string
23 isCurrent: boolean
24 repo: string
25 model: string
26 contextTokens: number
27 lastMainRequestAt: number | null
28 mainTtl: Ttl | null
29 isWorking: boolean
30 last60: number
31 today: number
32}
33export type BreakdownInput = {
34 totalTokens: number
35 rawMaxTokens: number
36 categories: readonly { name: string; tokens: number; kind: string }[]
37 memoryFiles: readonly { path: string; tokens: number }[]
38 mcpTools: readonly { serverName: string; tokens: number; isLoaded: boolean }[]
39 agents: readonly { agentType: string; tokens: number }[]
40}
41
42function isNum(x: unknown): x is number {
43 return typeof x === 'number' && Number.isFinite(x)
44}
45
46function isObj(x: unknown): x is Record<string, unknown> {
47 return typeof x === 'object' && x !== null && !Array.isArray(x)
48}
49
50function costOfCell(c: unknown): number {
51 return isObj(c) && isNum(c.cost) ? c.cost : 0
52}
53
54export function addCounts(a: Counts, b: Counts): Counts {
55 return {
56 input: a.input + b.input,
57 output: a.output + b.output,
58 cacheRead: a.cacheRead + b.cacheRead,
59 cacheWrite: a.cacheWrite + b.cacheWrite,
60 requests: a.requests + b.requests,
61 cost: a.cost + b.cost,
62 }
63}
64
65export function countsOf(usage: UsageLike, cost: number): Counts {
66 return {
67 input: tokens(usage.input_tokens),
68 output: tokens(usage.output_tokens),
69 cacheRead: tokens(usage.cache_read_input_tokens),
70 cacheWrite: tokens(usage.cache_creation_input_tokens),
71 requests: 1,
72 cost,
73 }
74}
75
76// Returns a new table; the input can be frozen state
77export function addTo(table: Record<string, Record<string, Counts>>, outer: string, inner: string, counts: Counts): Record<string, Record<string, Counts>> {
78 const row = table[outer] ?? {}
79 return { ...table, [outer]: { ...row, [inner]: addCounts(row[inner] ?? EMPTY, counts) } }
80}
81
82export function causeOf(previousAt: number | undefined, now: number, ttlMs: number): Cause {
83 if (previousAt === undefined) return 'start'
84 return now - previousAt > ttlMs ? 'resume' : 'growth'
85}
86
87export function addCause(causes: Causes, cause: Cause, tokenCount: number, cost: number): Causes {
88 return { ...causes, [cause]: { tokens: causes[cause].tokens + tokenCount, cost: causes[cause].cost + cost } }
89}
90
91export function contextOf(c: Counts): number {
92 return c.input + c.cacheRead + c.cacheWrite + c.output
93}
94
95// The main requests of the last 4 hours. State of an older version of the mod has requestTimes and no lifetimes: those times count with 1 hour, the lifetime it assumed
96export function requestsOf(main: Main): MainRequest[] {
97 if (Array.isArray(main.requests)) return main.requests
98 const old = (main as { requestTimes?: unknown }).requestTimes
99 return Array.isArray(old) ? old.filter(isNum).map((at) => ({ at, ttl: '1h' as const })) : []
100}
101
102// ttl: the lifetime that the cost of the request used. known: the confirmed lifetime, or null, for the tube.
103// A request that neither reads nor writes the cache (isCached false) refreshes no cache: the tube, the strip and the resumes stay
104export function mainAfter(main: Main, model: string, now: number, contextTokens: number, cause: Cause, writeCost: number, ttl: Ttl, known: Ttl | null, isCached: boolean = true): Main {
105 const since = now - STRIP_MS
106 const requests = requestsOf(main).filter((r) => r.at >= since)
107 if (!isCached) return { ...main, model, contextTokens, requests }
108 return {
109 ...main,
110 model,
111 lastRequestAt: now,
112 ttl: known,
113 contextTokens,
114 requests: [...requests, { at: now, ttl }],
115 resumes: [...main.resumes.filter((r) => r.at >= since), ...(cause === 'resume' ? [{ at: now, cost: writeCost }] : [])],
116 }
117}
118
119export function hourKey(ms: number): string {
120 return new Date(ms).toISOString().slice(0, 13)
121}
122
123export function hourStart(key: string): number {
124 return Date.parse(key + ':00:00.000Z')
125}
126
127export function pruneHours(hours: Hours, now: number): Hours {
128 const keep: Hours = {}
129 for (const [key, value] of Object.entries(hours)) if (hourStart(key) >= now - KEEP_MS) keep[key] = value
130 return keep
131}
132
133// A new reading starts when a percent moves a whole point or the reset changes.
134// A measure that keeps the percent moves the seenAt of the last reading, so that the Week tab paces the week up to the last measure
135export function addReadings(readings: Reading[], limits: readonly Limit[], at: number): Reading[] {
136 const next = [...readings]
137 for (const limit of limits) {
138 const i = next.map((r) => r.kind).lastIndexOf(limit.kind)
139 const last = i === -1 ? undefined : next[i]
140 if (!last || Math.abs(limit.percentUsed - last.percentUsed) >= 1 || last.resetsAt !== limit.resetsAt) {
141 next.push({ at, kind: limit.kind, percentUsed: limit.percentUsed, ...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {}) })
142 } else if (at > seenAtOf(last)) {
143 next[i] = { ...last, seenAt: at }
144 }
145 }
146 return next.slice(-MAX_READINGS)
147}
148
149// The time of the last measure of a reading
150export function seenAtOf(r: Reading): number {
151 return r.seenAt ?? r.at
152}
153
154export function rowsOf(totals: Totals): Row[] {
155 const rows: Row[] = []
156 for (const [model, scopes] of Object.entries(totals)) {
157 const names = Object.keys(scopes).sort((a, b) => (a === 'main' ? -1 : b === 'main' ? 1 : 0))
158 for (const scope of names) rows.push({ model, scope, counts: scopes[scope] })
159 }
160 return rows
161}
162
163export function sumAll(totals: Totals): Counts {
164 return rowsOf(totals).reduce((sum, row) => addCounts(sum, row.counts), EMPTY)
165}
166
167export function modelSums(totals: Totals): { model: string; counts: Counts }[] {
168 return Object.entries(totals).map(([model, scopes]) => ({
169 model,
170 counts: Object.values(scopes).reduce((sum, c) => addCounts(sum, c), EMPTY),
171 }))
172}
173
174export function runKey(run: Run): string {
175 return 'run:' + run.sessionId + ':' + run.startedAt
176}
177
178export function snapshotOf(run: Run, main: Main, readings: Reading[], hours: Hours, now: number): Snapshot {
179 return {
180 v: 1,
181 key: runKey(run),
182 sessionId: run.sessionId,
183 repo: run.repo,
184 model: main.model,
185 updatedAt: now,
186 lastMainRequestAt: main.lastRequestAt,
187 mainTtl: main.ttl ?? null,
188 contextTokens: main.contextTokens,
189 isWorking: main.isWorking,
190 readings: readings.filter((r) => r.kind === 'seven_day' && r.at >= now - KEEP_MS),
191 hours: pruneHours(hours, now),
192 }
193}
194
195export function parseSnapshot(x: unknown): Snapshot | null {
196 if (!isObj(x) || x.v !== 1) return null
197 if (typeof x.key !== 'string' || typeof x.sessionId !== 'string' || typeof x.repo !== 'string' || typeof x.model !== 'string') return null
198 if (!isNum(x.updatedAt) || !isNum(x.contextTokens) || typeof x.isWorking !== 'boolean') return null
199 if (!(x.lastMainRequestAt === null || isNum(x.lastMainRequestAt))) return null
200 if (!Array.isArray(x.readings) || !isObj(x.hours)) return null
201 if (!(x.mainTtl === undefined || x.mainTtl === null || x.mainTtl === '5m' || x.mainTtl === '1h')) return null
202 return x as unknown as Snapshot
203}
204
205export function costIn(hours: Hours, from: number, to: number): number {
206 let sum = 0
207 for (const [key, cells] of Object.entries(hours)) {
208 const start = hourStart(key)
209 if (!(start >= from && start < to) || !isObj(cells)) continue
210 for (const c of Object.values(cells)) sum += costOfCell(c)
211 }
212 return sum
213}
214
215// An estimate from the hourly buckets: the current hour, plus the part of the previous hour inside the last 60 minutes
216export function last60(hours: Hours, now: number): number {
217 const current = hourStart(hourKey(now))
218 const part = 1 - (now - current) / HOUR_MS
219 return costIn(hours, current, current + HOUR_MS) + costIn(hours, current - HOUR_MS, current) * part
220}
221
222export function localMidnight(now: number): number {
223 const d = new Date(now)
224 d.setHours(0, 0, 0, 0)
225 return d.getTime()
226}
227
228export function today(hours: Hours, now: number): number {
229 return costIn(hours, localMidnight(now), now + HOUR_MS)
230}
231
232// The spend of all sessions: the cost since local midnight and the weighted cost of the last 60 minutes, as the Now tab shows them per session
233export function spendOf(snaps: Snapshot[], now: number): { today: number; perHour: number } {
234 return snaps.reduce((sum, s) => ({ today: sum.today + today(s.hours, now), perHour: sum.perHour + last60(s.hours, now) }), { today: 0, perHour: 0 })
235}
236
237export function nowRows(snaps: Snapshot[], currentKey: string, now: number): NowRow[] {
238 const newest = new Map<string, Snapshot>()
239 for (const s of snaps) {
240 if (s.key !== currentKey && s.updatedAt < now - DAY_MS) continue
241 const seen = newest.get(s.sessionId)
242 if (s.key === currentKey || !seen || (seen.key !== currentKey && s.updatedAt > seen.updatedAt)) newest.set(s.sessionId, s)
243 }
244 return [...newest.values()]
245 .map((s) => ({
246 key: s.key,
247 isCurrent: s.key === currentKey,
248 repo: s.repo,
249 model: s.model,
250 contextTokens: s.contextTokens,
251 lastMainRequestAt: s.lastMainRequestAt,
252 mainTtl: s.mainTtl ?? null,
253 isWorking: s.isWorking && now - s.updatedAt <= WORKING_MS,
254 last60: last60(s.hours, now),
255 today: today(s.hours, now),
256 }))
257 .sort((a, b) => b.last60 - a.last60 || b.today - a.today)
258}
259
260export function mergeReadings(snaps: Snapshot[]): Reading[] {
261 return snaps
262 .flatMap((s) => s.readings)
263 .filter((r) => isObj(r) && isNum(r.at) && typeof r.kind === 'string' && isNum(r.percentUsed) && (r.resetsAt === undefined || typeof r.resetsAt === 'string') && (r.seenAt === undefined || isNum(r.seenAt)))
264 .sort((a, b) => a.at - b.at)
265}
266
267export function weekOf(readings: Reading[], now: number): Week {
268 // The reading with the last measure gives the percent, and the time of that measure ends the pace of the projection
269 const latest = readings.filter((r) => r.kind === 'seven_day').sort((a, b) => seenAtOf(a) - seenAtOf(b)).pop()
270 const resetAt = latest?.resetsAt ? Date.parse(latest.resetsAt) : Number.NaN
271 if (!latest || !Number.isFinite(resetAt)) return { start: now - 7 * DAY_MS, resetAt: null, percent: null, readAt: null }
272 if (resetAt <= now) {
273 const weeks = Math.floor((now - resetAt) / (7 * DAY_MS)) + 1
274 const next = resetAt + weeks * 7 * DAY_MS
275 return { start: next - 7 * DAY_MS, resetAt: next, percent: null, readAt: null }
276 }
277 return { start: resetAt - 7 * DAY_MS, resetAt, percent: latest.percentUsed, readAt: seenAtOf(latest) }
278}
279
280function splitKey(name: string): [string, string] {
281 const i = name.indexOf('|')
282 return i === -1 ? [name, 'main'] : [name.slice(0, i), name.slice(i + 1)]
283}
284
285function sorted(map: Map<string, number>, unpriced: Set<string> = new Set(), estimated: Set<string> = new Set()): Share[] {
286 return [...map.entries()]
287 .map(([name, cost]) => ({ name, cost, ...(unpriced.has(name) ? { isUnpriced: true } : {}), ...(estimated.has(name) ? { isEstimated: true } : {}) }))
288 .sort((a, b) => b.cost - a.cost)
289}
290
291export function groupWeek(snaps: Snapshot[], from: number, now: number): { byRepo: Share[]; byModelScope: Share[]; total: number } {
292 const byRepo = new Map<string, number>()
293 const byModelScope = new Map<string, number>()
294 const unpriced = new Set<string>()
295 const estimated = new Set<string>()
296 let total = 0
297 for (const s of snaps) {
298 for (const [key, cells] of Object.entries(s.hours)) {
299 const start = hourStart(key)
300 if (!(start >= from && start <= now) || !isObj(cells)) continue
301 for (const [name, c] of Object.entries(cells)) {
302 const cost = costOfCell(c)
303 const [model, scope] = splitKey(name)
304 const label = shortModel(model) + ' ' + scope
305 byRepo.set(s.repo, (byRepo.get(s.repo) ?? 0) + cost)
306 byModelScope.set(label, (byModelScope.get(label) ?? 0) + cost)
307 const info = priceInfo(model)
308 if (info === undefined) unpriced.add(label)
309 else if (info.source === 'fallback') estimated.add(label)
310 total += cost
311 }
312 }
313 }
314 return { byRepo: sorted(byRepo), byModelScope: sorted(byModelScope, unpriced, estimated), total }
315}
316
317function largestFirst(rows: BreakdownRow[]): BreakdownRow[] {
318 return [...rows].sort((a, b) => b.tokens - a.tokens)
319}
320
321export function breakdownOf(input: BreakdownInput): Breakdown {
322 const byServer = new Map<string, number>()
323 for (const tool of input.mcpTools) if (tool.isLoaded) byServer.set(tool.serverName, (byServer.get(tool.serverName) ?? 0) + tool.tokens)
324 return {
325 total: input.totalTokens,
326 max: input.rawMaxTokens,
327 categories: largestFirst(input.categories.filter((c) => c.kind === 'used').map((c) => ({ name: c.name, tokens: c.tokens }))),
328 memoryFiles: largestFirst(input.memoryFiles.map((m) => ({ name: m.path, tokens: m.tokens }))).slice(0, 10),
329 mcpServers: largestFirst([...byServer.entries()].map(([name, tokenCount]) => ({ name, tokens: tokenCount }))),
330 agents: largestFirst(input.agents.map((a) => ({ name: a.agentType, tokens: a.tokens }))),
331 }
332}
333hooks/view.ts 842 lines1import type { Breakdown, Cause, Causes, Counts, Limit, Main, MainRequest, Recommend, Resume, Snapshot } from '../types'
2import { actionOf, weekRange, type Action } from './advice'
3import { cell, dayTime, desktopCells, fitColumns, formatMoney, formatPercent, formatTokens, historyCells, limitsAgeText, markedCell, placeMarks, projectionText, shortModel, tubeParts, unknownLabel, UNKNOWN_LIFE, weekDayNames, type Column, type HistoryCell, type PlacedMarks, type ResumeMark } from './format'
4import { priceInfo, rewarmCost } from './prices'
5import { RECOMMEND_SCOPE, priceSourceOf } from './recommend'
6import { barSvg, defaultTtl, fraction, heat, heatText, minutesLeft, sparkSvg, stageOf, stripCellAt, stripCells, stripSvg, tubeAlt, tubeCells, type Cell, type Stage, type StripCell } from './temperature'
7import { STRIP_MS, groupWeek, mergeReadings, weekOf, type NowRow, type Row, type Share } from './tally'
8
9type El = (props: Record<string, any>) => unknown
10// Svg exists only in the element table of the remote surfaces. Markdown is in every table; the tests of the tabs leave it out
11export type Els = { Box: El; Text: El; Button: El; Svg?: El; Markdown?: El }
12export type CellValue = string | unknown[]
13
14const L = (width: number): Column => ({ width, align: 'left' })
15const R = (width: number): Column => ({ width, align: 'right' })
16
17// A money column is 10 cells wide: it holds $9,999.99 and the one free cell that cell() keeps
18export const NOW_COLUMNS: Column[] = [L(22), L(21), L(11), R(6), R(10), R(10)]
19export const SESSION_COLUMNS: Column[] = [L(11), L(16), R(5), R(7), R(9), R(7), R(8), R(10), R(7)]
20export const CAUSE_COLUMNS: Column[] = [L(14), L(21), R(8), R(10), R(6)]
21export const HISTORY_COLUMNS: Column[] = [L(16), L(49)]
22export const WEEK_COLUMNS: Column[] = [L(26), L(21), R(10), R(6)]
23export const WHY_COLUMNS: Column[] = [L(26), L(21), R(9), R(7)]
24export const TAB_LABELS = ['Now', 'Session', 'Week', 'Why', 'Help']
25
26// The cost cell of a model without a price
27const UNPRICED = 'unpriced'
28// The note under the main table of the Session tab
29const COST_NOTE = 'estimate: the requests this mod saw, at API prices. /cost: the figure of Claude Code. It also counts requests that the mod does not see, for example compaction.'
30const BAND_TUBE_CELLS = 10
31// The desktop tube is an Svg of 90 by 14 CSS pixels: 11.6 cells of the code font of the desktop app
32const BAND_DESKTOP_TUBE_CELLS = 12
33// The band keeps these free cells, a margin for the width estimate of the proportional font on the desktop
34const BAND_MARGIN = 4
35// The two buttons at the right end of the band: 2 cells of gap, `[ details ]` (the longer label of the pane button), 2 cells of gap and the hide button `×`.
36// The band keeps these cells, so the buttons stay when parts of the text leave. On the desktop the native `details` button without a hotkey
37// takes about 6.5 cells of the code font and `×` about 1, measured on a screenshot of 2026-10-08, so the same 16 cells hold them
38const BAND_BUTTON_GAP = 2
39const HIDE_GLYPH = '×'
40const BAND_BUTTON_CELLS = BAND_BUTTON_GAP + '[ details ]'.length + BAND_BUTTON_GAP + HIDE_GLYPH.length
41// The labels of the pane button, closed and open
42const PANE_BUTTON_LABELS = { closed: 'details', open: 'close' } as const
43// The key that presses the pane button once the band has the focus, on the terminal only: the desktop draws a hotkey as a badge that takes width, and a click presses the button there.
44// A letter, because a bare digit in an empty prompt presses a band button
45const PANE_BUTTON_HOTKEY = 't'
46// The font table of desktopCells matched the band text of one screenshot to 0.1%, and of two more within 3%.
47// The band counts the desktop text 4% wider, so that it leaves out a part before the line wraps
48const DESKTOP_TEXT_FACTOR = 1.04
49const NOW_TUBE_CELLS = 8
50const BAR_CELLS = 20
51// A day of the week history is 2 cells of 12 hours
52const DAY_CELLS = 2
53const CAUSES: Cause[] = ['start', 'growth', 'resume']
54
55// The columns that go first when the pane is narrower than the table: the indexes, in order
56const NOW_DROP = [3, 2, 5, 4]
57const SESSION_DROP = [3, 2, 4, 6, 8]
58const CAUSE_DROP = [1, 2]
59const HISTORY_DROP = [0]
60const WEEK_DROP = [1]
61const WEEK_HEAD_DROP = [2]
62const WHY_DROP = [1, 3]
63
64// A limit in the band: its name and percent, the heat of the percent when it runs hot (5h full soon, week used up, any limit at 100%) or null,
65// the range of the week (`lasts until reset` dimmed, `runs out Fri 14:00` in heat) or null, and isKept for a limit that never leaves: one that the action names or one at 100%
66export type BandLimit = { kind: string; name: string; percent: string; heat: number | null; range: { text: string; heat: number | null } | null; isKept: boolean }
67
68// The band as a dashboard. lead never leaves: the minutes, `in turn`, or the label of an unknown cache life. context and price can leave.
69// action is the one action of the band, or null. spend replaces the limits with an API key: the cost of the sessions on this Mac today and in the last 60 minutes
70export type BandData = {
71 fraction: number | null
72 stage: Stage | null
73 lead: string
74 context: string
75 price: string
76 action: Action | null
77 limits: BandLimit[]
78 limitsAge: string
79 spend: { today: string; perHour: string } | null
80}
81
82// The cost of the sessions on this Mac since midnight and in the last 60 minutes, for a session without plan limits (an API key)
83export type Spend = { today: number; perHour: number }
84
85export type SessionData = {
86 rows: Row[]
87 total: Counts
88 causes: Causes
89 requests: MainRequest[]
90 resumes: Resume[]
91 now: number
92 usd: number | null
93}
94
95export type WeekData = {
96 percent: number | null
97 resetAt: number | null
98 projection: string
99 // The start of the week: the weekday of the first history cell
100 start: number
101 history: HistoryCell[]
102 byRepo: Share[]
103 byModelScope: Share[]
104 total: number
105}
106
107function text(E: Els, value: string, style: Record<string, unknown> = {}): unknown {
108 return E.Text({ ...style, children: [value] })
109}
110
111function share(part: number, whole: number): string {
112 return whole > 0 ? formatPercent(Math.round((part / whole) * 100)) : ''
113}
114
115// The heat of a range that runs out: the colour of a part that runs hot
116const RUNS_OUT_HEAT = 0.9
117const LIMIT_NAMES: Record<string, string> = { seven_day: 'week', five_hour: '5h', spend_limit: 'spend' }
118const LIMIT_ORDER = ['seven_day', 'five_hour', 'spend_limit']
119
120function limitRank(kind: string): number {
121 const i = LIMIT_ORDER.indexOf(kind)
122 return i === -1 ? LIMIT_ORDER.length : i
123}
124
125// The limits of the band: the week with its range, then the 5-hour window and the others. The limit that the action names comes first and keeps its place
126function bandLimits(limits: readonly Limit[], limitsAt: number | null, now: number, action: Action | null): BandLimit[] {
127 const items = [...limits]
128 .sort((a, b) => limitRank(a.kind) - limitRank(b.kind))
129 .map((l): BandLimit => {
130 const isWeek = l.kind === 'seven_day'
131 const range = isWeek ? weekRange(l, limitsAt, now) : null
132 const isUsedUp = isWeek && action?.kind === 'weekUsedUp'
133 const isFull = l.kind === 'five_hour' && action?.kind === 'fiveHour'
134 // A limit at 100% or more runs hot and stays in the band whatever the action
135 const isAtLimit = l.percentUsed >= 100
136 return {
137 kind: l.kind,
138 name: LIMIT_NAMES[l.kind] ?? l.kind,
139 percent: formatPercent(l.percentUsed),
140 heat: isUsedUp || isAtLimit ? 1 : isFull ? l.percentUsed / 100 : null,
141 range: range === null ? null : range.kind === 'lasts' ? { text: 'lasts until reset', heat: null } : { text: 'runs out ' + dayTime(range.at), heat: RUNS_OUT_HEAT },
142 isKept: isUsedUp || isFull || isAtLimit,
143 }
144 })
145 return action?.kind === 'fiveHour' ? [...items.filter((l) => l.kind === 'five_hour'), ...items.filter((l) => l.kind !== 'five_hour')] : items
146}
147
148export function bandData(main: Main, limits: Limit[], limitsAt: number | null, now: number, spend: Spend | null = null): BandData | null {
149 const ttl = main.ttl ?? null
150 const f = fraction(main.lastRequestAt, now, main.isWorking, ttl)
151 const stage = f === null ? null : stageOf(f, main.isWorking)
152 // The re-warm cost of a model without a price is left out; a cost from a fallback price shows with ≈.
153 // The next message writes with the lifetime of the last one, or with the default before the mod knows it
154 const info = priceInfo(main.model)
155 const rewarm = info === undefined ? null : rewarmCost(main.model, main.contextTokens, ttl ?? defaultTtl(false))
156 // Without a known lifetime the tube shows no countdown, only the time since the last request
157 const parts =
158 stage !== null
159 ? tubeParts(stage, main.lastRequestAt, now, main.contextTokens, rewarm, info?.source === 'fallback', ttl)
160 : { lead: main.lastRequestAt === null ? '' : unknownLabel(main.lastRequestAt, now), context: main.lastRequestAt !== null && main.contextTokens > 0 ? ' · ' + formatTokens(main.contextTokens) + ' cached' : '', price: '' }
161 const action = actionOf({ main, limits, limitsAt, now })
162 const items = bandLimits(limits, limitsAt, now, action)
163 const spent = limits.length === 0 && spend !== null && (spend.today > 0 || parts.lead !== '') ? { today: 'today ' + formatMoney(spend.today), perHour: formatMoney(spend.perHour) + '/h' } : null
164 if (parts.lead === '' && items.length === 0 && spent === null) return null
165 return { fraction: f, stage, ...parts, action, limits: items, limitsAge: items.length === 0 ? '' : limitsAgeText(limitsAt, now), spend: spent }
166}
167
168export function weekData(snaps: Snapshot[], now: number): WeekData {
169 const readings = mergeReadings(snaps)
170 const week = weekOf(readings, now)
171 const groups = groupWeek(snaps, week.start, now)
172 return {
173 percent: week.percent,
174 resetAt: week.resetAt,
175 projection: projectionText(week.percent, week.start, week.readAt, week.resetAt, now),
176 start: week.start,
177 history: historyCells(readings, week.start, now),
178 ...groups,
179 }
180}
181
182function nowStage(r: NowRow, now: number): { f: number | null; stage: Stage | null; minutes: string } {
183 const f = fraction(r.lastMainRequestAt, now, r.isWorking, r.mainTtl)
184 const stage = f === null ? null : stageOf(f, r.isWorking)
185 const isCounting = stage === 'HOT' || stage === 'WARM' || stage === 'COOLING'
186 return { f, stage, minutes: isCounting && r.lastMainRequestAt !== null && r.mainTtl !== null ? minutesLeft(r.lastMainRequestAt, now, r.mainTtl) + 'm' : '' }
187}
188
189// The cache column as text: 8 tube cells, space, stage padded to 7, space, minutes right-aligned in 4; cell() pads it to 22
190export function nowCells(r: NowRow, now: number): string[] {
191 const { f, stage, minutes } = nowStage(r, now)
192 const cache = f === null || stage === null ? '' : tubeCells(f, NOW_TUBE_CELLS).map((c) => c.char).join('') + ' ' + stage.padEnd(7) + ' ' + minutes.padStart(4)
193 // A row without a request has no model yet; a model without a price shows its cost as unpriced
194 const isUnpriced = r.model !== '' && priceInfo(r.model) === undefined
195 return [cache, r.repo, r.model === '' ? '' : shortModel(r.model), formatTokens(r.contextTokens), isUnpriced ? UNPRICED : formatMoney(r.last60), isUnpriced ? UNPRICED : formatMoney(r.today)]
196}
197
198export function sessionCells(r: Row, total: Counts): string[] {
199 const c = r.counts
200 const info = priceInfo(r.model)
201 const isUnpriced = info === undefined
202 const isEstimated = info?.source === 'fallback'
203 // A cost from a fallback price marks the model name with ≈. The Now cells and the total mix models, so they have no mark
204 return [isEstimated ? markedCell(shortModel(r.model), SESSION_COLUMNS[0]) : shortModel(r.model), r.scope, String(c.requests), formatTokens(c.input), formatTokens(c.cacheWrite), formatTokens(c.cacheRead), formatTokens(c.output), isUnpriced ? UNPRICED : formatMoney(c.cost), isUnpriced ? '' : share(c.cost, total.cost)]
205}
206
207// On the desktop the tube is one SVG: the cells of the terminal tube are not one width in a proportional font
208function isDesktop(E: Els, surface: string): boolean {
209 return surface === 'desktop' && E.Svg !== undefined
210}
211
212// The band tube has its own size. The tube of a table cell has none, so the surface scales it to the box and it cannot overlap the next column
213function tubeSvgEl(E: Els, f: number, n: number, isSized: boolean): unknown {
214 const props = { source: barSvg(f, n), alt: tubeAlt(f) }
215 return E.Svg!(isSized ? { ...props, width: n * 9, height: 14 } : props)
216}
217
218// One Text for each tube cell: empty cells are dimmed. A cell is a block glyph, a graphic, so it keeps the colour of heat
219export function cellTexts(E: Els, cells: Cell[]): unknown[] {
220 return cells.map((c) => (c.isEmpty ? text(E, c.char, { dimColor: true }) : text(E, c.char, { color: c.color })))
221}
222
223export function tubeEls(E: Els, f: number, n: number, isFramed: boolean, surface: string = 'terminal'): unknown[] {
224 if (isDesktop(E, surface)) return [tubeSvgEl(E, f, n, isFramed)]
225 const parts: unknown[] = []
226 if (isFramed) parts.push(text(E, '▕', { dimColor: true }))
227 parts.push(...cellTexts(E, tubeCells(f, n)))
228 if (isFramed) parts.push(text(E, '▏', { dimColor: true }))
229 return parts
230}
231
232// An Svg has no width of its own: the Box gives it the width of its cells in the monospace metric, and the markup scales to it
233export function svgBox(E: Els, source: string, alt: string, cells: number): unknown {
234 return E.Box({ width: cells, flexShrink: 0, children: [E.Svg!({ source, alt })] })
235}
236
237// Spaces fill the part of a column that the cells leave
238function padTo(E: Els, parts: unknown[], used: number, width: number): unknown[] {
239 return width > used ? [...parts, text(E, ' '.repeat(width - used))] : parts
240}
241
242// The children of a column box of the given width that holds a bar of n cells
243export function barEls(E: Els, f: number, n: number, width: number, alt: string, surface: string = 'terminal'): unknown[] {
244 if (isDesktop(E, surface)) return [svgBox(E, barSvg(f, n), alt, n)]
245 return padTo(E, cellTexts(E, tubeCells(f, n)), n, width)
246}
247
248// The cache strip: warm cells in their colour, cold cells dimmed, a space where no request came before
249export function stripEls(E: Els, cells: StripCell[], width: number, alt: string, surface: string = 'terminal'): unknown[] {
250 if (isDesktop(E, surface)) return [svgBox(E, stripSvg(cells), alt, cells.length)]
251 const texts = cells.map((c) => (c.color === '' ? text(E, ' ') : c.char === '█' ? text(E, c.char, { color: c.color }) : text(E, c.char, { dimColor: true })))
252 return padTo(E, texts, cells.length, width)
253}
254
255// The week history: a column in its heat colour (a block glyph is a graphic), a dimmed empty cell for a past period without a reading, a dimmed dot for a future period
256export function sparkEls(E: Els, cells: HistoryCell[], width: number, alt: string, surface: string = 'terminal'): unknown[] {
257 if (isDesktop(E, surface)) return [svgBox(E, sparkSvg(cells), alt, cells.length)]
258 const texts = cells.map((c) => {
259 if (c.percent !== null) return text(E, c.char, { color: heat(c.percent / 100) })
260 return text(E, c.isFuture ? '·' : '░', { dimColor: true })
261 })
262 return padTo(E, texts, cells.length, width)
263}
264
265// The day axis under the week history: the name of each day over its 2 cells, dimmed.
266// On the desktop each name is a Box of 2 cells, because a proportional font does not place padded text under the cells
267export function dayAxisEls(E: Els, names: string[], width: number, surface: string = 'terminal'): unknown[] {
268 // On the desktop the letters have different widths, so each letter sits in the middle of the 2 cells of its day
269 if (isDesktop(E, surface)) return names.map((name) => E.Box({ width: DAY_CELLS, flexShrink: 0, justifyContent: 'center', children: [text(E, name.trim(), { dimColor: true })] }))
270 const texts = names.map((name) => text(E, name, { dimColor: true }))
271 return padTo(E, texts, names.length * DAY_CELLS, width)
272}
273
274// The optional parts of the band, in the order that they leave when the band is too wide.
275// calmRange is `lasts until reset`, minorLimits the limits besides the week. A range that runs out, the limit that the action names, the tube,
276// the stage word, the lead and the action never leave
277const BAND_DROP = ['age', 'calmRange', 'minorLimits', 'price', 'perHour', 'context', 'week', 'today'] as const
278type BandPart = (typeof BAND_DROP)[number]
279type Segment = { value: string; style?: Record<string, unknown> }
280
281// While an action shows, these parts leave at any width: the action is the one thing to read. A cold cache keeps its price, the cost of the next message
282function hiddenByAction(d: BandData): BandPart[] {
283 if (d.action === null) return []
284 return ['calmRange', 'minorLimits', ...(d.stage === 'COLD' ? [] : (['price'] as BandPart[]))]
285}
286
287const dim = (value: string): Segment => ({ value, style: { dimColor: true } })
288const heated = (value: string, heat: number | null): Segment => (heat === null ? { value } : { value, style: { color: heatText(heat) } })
289
290// The parts of one limit that show: its name and percent, and its range
291function limitGroup(l: BandLimit, shown: Set<BandPart>): Segment[] {
292 const isWeek = l.kind === 'seven_day'
293 const isNameShown = l.isKept || (isWeek ? shown.has('week') : shown.has('minorLimits'))
294 const isRangeShown = l.range !== null && (l.range.heat !== null || shown.has('calmRange'))
295 const group: Segment[] = isNameShown ? [{ value: l.name + ' ' }, heated(l.percent, l.heat)] : []
296 if (isRangeShown) group.push(...(isNameShown ? [dim(' · ')] : []), l.range!.heat === null ? dim(l.range!.text) : heated(l.range!.text, l.range!.heat))
297 return group
298}
299
300// The text of the band after the tube: the stage word (in the text colour of heat) and its label, the action, the limits or the spend, with a separator between them
301function bandSegments(d: BandData, shown: Set<BandPart>): Segment[] {
302 const segments: Segment[] = []
303 const label = d.lead + (shown.has('context') ? d.context : '') + (shown.has('context') && shown.has('price') ? d.price : '')
304 if (d.fraction !== null && d.stage !== null) segments.push({ value: ' ' }, { value: d.stage, style: { bold: true, color: heatText(d.fraction) } }, dim(' ' + label))
305 else if (d.lead !== '') segments.push(dim(label))
306 const add = (group: Segment[]) => {
307 if (group.length === 0) return
308 if (segments.length > 0) segments.push(dim(' | '))
309 segments.push(...group)
310 }
311 if (d.action !== null) add([{ value: d.action.verb, style: { bold: true } }, ...(d.action.rest === '' ? [] : [{ value: d.action.rest }])])
312 const groups = d.limits.map((l) => limitGroup(l, shown)).filter((g) => g.length > 0)
313 if (groups.length > 0 && shown.has('age') && d.limitsAge !== '') groups[groups.length - 1].push(dim(' ' + d.limitsAge))
314 for (const group of groups) add(group)
315 if (d.spend !== null) add([...(shown.has('today') ? [{ value: d.spend.today }] : []), ...(shown.has('perHour') ? [dim((shown.has('today') ? ' · ' : '') + d.spend.perHour)] : [])])
316 return segments
317}
318
319function cellCount(value: string): number {
320 return Array.from(value).length
321}
322
323// The width of the band in cells: the tube and the text.
324// On the terminal the tube is 12 cells (10 and the two frame cells) and each character of the text is one cell.
325// On the desktop the tube is the Svg, and the text is proportional, so each character has its own width (desktopCells)
326export function bandCells(text: string, isTubeShown: boolean, isOnDesktop: boolean): number {
327 const tube = !isTubeShown ? 0 : isOnDesktop ? BAND_DESKTOP_TUBE_CELLS : BAND_TUBE_CELLS + 2
328 return tube + (isOnDesktop ? desktopCells(text) * DESKTOP_TEXT_FACTOR : cellCount(text))
329}
330
331// The buttons of the band: the label of the pane button, what a press of it runs, and what the hide button runs
332export type BandButtons = { isPaneOpen: boolean; onPane: () => unknown; onHide: () => unknown }
333
334// The buttons at the right end. The focus of the band starts on the pane button, so ctrl+x tab and Enter press it, and Tab moves to the hide button.
335// The key of the pane button stays when its label changes, so the focus stays on it
336function bandButtonEls(E: Els, buttons: BandButtons, isOnDesktop: boolean): unknown[] {
337 const label = buttons.isPaneOpen ? PANE_BUTTON_LABELS.open : PANE_BUTTON_LABELS.closed
338 const pane = E.Button({ key: 'pane', label, ...(isOnDesktop ? {} : { hotkey: PANE_BUTTON_HOTKEY }), autoFocus: true, dimColor: true, onPress: buttons.onPane })
339 // The desktop draws no close mark for the dismiss role in the band: it draws the label, so the label is the glyph on both surfaces
340 const hide = E.Button({ key: 'band-hide', label: HIDE_GLYPH, role: 'dismiss', plain: true, dimColor: true, onPress: buttons.onHide })
341 return [pane, hide].map((button) => E.Box({ flexShrink: 0, marginLeft: BAND_BUTTON_GAP, children: [button] }))
342}
343
344export function bandEls(E: Els, d: BandData, surface: string = 'terminal', available?: number, buttons?: BandButtons): unknown {
345 const isTubeShown = d.fraction !== null && d.stage !== null
346 const isOnDesktop = isDesktop(E, surface)
347 const onDesktop = isTubeShown && isOnDesktop
348 const budget = typeof available === 'number' && Number.isFinite(available) ? available - BAND_MARGIN - (buttons === undefined ? 0 : BAND_BUTTON_CELLS) : Infinity
349 const widthOf = (segments: Segment[]) => bandCells(segments.map((s) => s.value).join(''), isTubeShown, isOnDesktop)
350 const present: Record<BandPart, boolean> = {
351 age: d.limits.length > 0 && d.limitsAge !== '',
352 calmRange: d.limits.some((l) => l.range !== null && l.range.heat === null),
353 minorLimits: d.limits.some((l) => l.kind !== 'seven_day' && !l.isKept),
354 price: d.price !== '',
355 perHour: d.spend !== null,
356 context: d.context !== '',
357 week: d.limits.some((l) => l.kind === 'seven_day' && !l.isKept),
358 today: d.spend !== null,
359 }
360 const hidden = hiddenByAction(d)
361 const shown = new Set<BandPart>(BAND_DROP.filter((part) => present[part] && !hidden.includes(part)))
362 let segments = bandSegments(d, shown)
363 // Leave out parts in the drop order until the band fits. The tube, the stage word, the lead and the action stay, and so does the last text of a band without a tube
364 for (const part of BAND_DROP) {
365 if (widthOf(segments) <= budget) break
366 if (!shown.has(part)) continue
367 shown.delete(part)
368 const rest = bandSegments(d, shown)
369 if (rest.length === 0) {
370 shown.add(part)
371 break
372 }
373 segments = rest
374 }
375 const parts = segments.map((s) => text(E, s.value, s.style))
376 if (isTubeShown && !onDesktop) parts.unshift(...tubeEls(E, d.fraction!, BAND_TUBE_CELLS, true, surface))
377 const line = E.Text({ wrap: 'truncate-end', children: parts })
378 // A Box that grows between the text and the buttons puts the buttons at the right end
379 const right = buttons === undefined ? [] : [E.Box({ flexGrow: 1, children: [] }), ...bandButtonEls(E, buttons, isOnDesktop)]
380 // A Text takes no flex props, so a Box with flexShrink 1 holds the text that is cut
381 if (!onDesktop) return right.length === 0 ? line : E.Box({ flexDirection: 'row', children: [E.Box({ flexShrink: 1, children: [line] }), ...right] })
382 const tube = E.Box({ flexShrink: 0, children: tubeEls(E, d.fraction!, BAND_TUBE_CELLS, true, surface) })
383 return E.Box({ flexDirection: 'row', alignItems: 'center', children: [tube, E.Box({ flexShrink: 1, children: [line] }), ...right] })
384}
385
386type TableOptions = { headerRows?: number; boldRows?: number[]; dimRows?: number[]; dimColumns?: number[]; selectedRows?: number[]; available?: number; dropOrder?: number[] }
387
388// The background of a selected row: a theme key of Claude Code, so it follows the dark and the light theme
389const SELECTED_BACKGROUND = 'selectionBg'
390
391// The style of a string cell: header row, dim row, bold row and selected row, dim column, in this order
392function cellStyle(options: TableOptions, ri: number, ci: number): Record<string, unknown> {
393 if (ri < (options.headerRows ?? 0) || (options.dimRows ?? []).includes(ri)) return { dimColor: true }
394 if ((options.boldRows ?? []).includes(ri) || (options.selectedRows ?? []).includes(ri)) return { bold: true }
395 return (options.dimColumns ?? []).includes(ci) ? { dimColor: true } : {}
396}
397
398export function tableEls(E: Els, columns: Column[], rows: CellValue[][], options: TableOptions = {}): unknown {
399 const kept = fitColumns(columns.map((c) => c.width), options.dropOrder ?? [], options.available)
400 return E.Box({
401 flexDirection: 'column',
402 children: rows.map((r, ri) =>
403 E.Box({
404 flexDirection: 'row',
405 // A selected row has one background on the whole row Box: it covers the Svg of the desktop and the gaps between the columns, and it ends with the table
406 ...((options.selectedRows ?? []).includes(ri) ? { backgroundColor: SELECTED_BACKGROUND, width: kept.reduce((sum, ci) => sum + columns[ci].width, 0), flexShrink: 0 } : {}),
407 children: kept.map((ci) => {
408 const column = columns[ci]
409 const value = r[ci] ?? ''
410 return E.Box({
411 width: column.width,
412 flexShrink: 0,
413 flexDirection: 'row',
414 justifyContent: column.align === 'right' ? 'flex-end' : 'flex-start',
415 children: typeof value === 'string' ? [text(E, cell(value, column), cellStyle(options, ri, ci))] : value,
416 })
417 }),
418 }),
419 ),
420 })
421}
422
423export function tabsEls(E: Els, current: number, onTab: (n: number) => unknown): unknown {
424 return E.Box({
425 flexDirection: 'row',
426 columnGap: 3,
427 children: TAB_LABELS.map((label, i) =>
428 E.Button({ key: 'tab-' + (i + 1), label, hotkey: String(i + 1), plain: true, dimColor: current !== i + 1, onPress: () => onTab(i + 1) }),
429 ),
430 })
431}
432
433// The cache column is four boxes of fixed width: tube 8, stage 8, minutes 5 and a spacer of 1. The stage word is text, so it has the text colour of heat
434function cacheEls(E: Els, f: number, stage: Stage, minutes: string, surface: string): unknown {
435 const color = heatText(f)
436 return [
437 E.Box({ width: 8, flexShrink: 0, flexDirection: 'row', children: tubeEls(E, f, NOW_TUBE_CELLS, false, surface) }),
438 E.Box({ width: 8, flexShrink: 0, flexDirection: 'row', children: [text(E, (' ' + stage).padEnd(8), { bold: true, color })] }),
439 E.Box({ width: 5, flexShrink: 0, flexDirection: 'row', justifyContent: 'flex-end', children: [text(E, minutes.padStart(5), { dimColor: true })] }),
440 E.Box({ width: 1, flexShrink: 0, flexDirection: 'row', children: [text(E, ' ')] }),
441 ]
442}
443
444export function nowEls(E: Els, rows: NowRow[], now: number, surface: string = 'terminal', available?: number): unknown {
445 if (rows.length === 0) return text(E, 'No session has written data in the last 24 hours.', { dimColor: true })
446 const header: CellValue[] = ['cache', 'repo', 'model', 'ctx', '60 min', 'today']
447 const body = rows.map((r) => {
448 const cells: CellValue[] = nowCells(r, now)
449 const { f, stage, minutes } = nowStage(r, now)
450 if (f !== null && stage !== null) cells[0] = cacheEls(E, f, stage, minutes, surface) as unknown[]
451 return cells
452 })
453 // The current session is the selected row: the header is row 0, so the index of a row is its place in the list plus 1
454 const selectedRows = rows.flatMap((r, i) => (r.isCurrent ? [i + 1] : []))
455 return tableEls(E, NOW_COLUMNS, [header, ...body], { headerRows: 1, selectedRows, available, dropOrder: NOW_DROP })
456}
457
458function mainTable(E: Els, d: SessionData, available: number | undefined): unknown {
459 const header = ['model', 'scope', 'req', 'input', 'c.write', 'c.read', 'output', 'cost', 'share']
460 const rows = d.rows.map((r) => sessionCells(r, d.total))
461 const t = d.total
462 // The total is the estimate of the mod: its scope cell is dimmed and the rest of the row is bold
463 const total: CellValue[] = ['total', [text(E, cell('estimate', SESSION_COLUMNS[1]), { dimColor: true })], String(t.requests), formatTokens(t.input), formatTokens(t.cacheWrite), formatTokens(t.cacheRead), formatTokens(t.output), formatMoney(t.cost), '']
464 // The cost that Claude Code reports with /cost sits under the cost column, after the total
465 const reported = d.usd === null ? [] : [['reported', 'by /cost', '', '', '', '', '', formatMoney(d.usd), '']]
466 return tableEls(E, SESSION_COLUMNS, [header, ...rows, total, ...reported], { headerRows: 1, boldRows: [rows.length + 1], dimRows: reported.length > 0 ? [rows.length + 2] : [], available, dropOrder: SESSION_DROP })
467}
468
469// The alt of a bar that shows a part of a whole
470function shareAlt(part: number): string {
471 return 'share ' + formatPercent(Math.round(part * 100))
472}
473
474function causeTable(E: Els, causes: Causes, available: number | undefined, surface: string): unknown {
475 const writeCost = CAUSES.reduce((sum, key) => sum + causes[key].cost, 0)
476 const rows = CAUSES.map((key) => {
477 const part = writeCost > 0 ? causes[key].cost / writeCost : 0
478 return [key, barEls(E, part, BAR_CELLS, CAUSE_COLUMNS[1].width, shareAlt(part), surface), formatTokens(causes[key].tokens), formatMoney(causes[key].cost), formatPercent(Math.round(part * 100))]
479 })
480 return tableEls(E, CAUSE_COLUMNS, [['cache writes', '', 'tokens', 'cost', 'share'], ...rows], { headerRows: 1, available, dropOrder: CAUSE_DROP })
481}
482
483// A piece of text at a cell offset of a row: its parts follow each other from the cell `at`
484type Piece = { at: number; parts: { text: string; style: Record<string, unknown> }[] }
485
486// The resume mark is a text mark next to its cost, so it has the text colour of heat
487const MARK_STYLE = { color: heatText(1) }
488const AXIS_STYLE = { dimColor: true }
489const AXIS_HOURS = 4
490
491// The pieces of a row in a column of `width` cells, each at its cell offset.
492// The desktop font is proportional, so a Text could not place a piece by spaces: each piece gets a Box of fixed width that runs to the next piece or to the end of the column.
493// The terminal places the pieces with text cells.
494function offsetEls(E: Els, pieces: Piece[], width: number, surface: string): unknown[] {
495 const parts = (piece: Piece) => piece.parts.map((part) => text(E, part.text, part.style))
496 if (isDesktop(E, surface)) {
497 const lead = pieces[0].at > 0 ? [E.Box({ width: pieces[0].at, flexShrink: 0, children: [] })] : []
498 return [...lead, ...pieces.map((piece, i) => E.Box({ width: (pieces[i + 1]?.at ?? width) - piece.at, flexShrink: 0, flexDirection: 'row', children: parts(piece) }))]
499 }
500 const els: unknown[] = []
501 let used = 0
502 for (const piece of pieces) {
503 if (piece.at > used) els.push(text(E, ' '.repeat(piece.at - used)))
504 els.push(...parts(piece))
505 used = piece.at + piece.parts.reduce((sum, part) => sum + Array.from(part.text).length, 0)
506 }
507 return padTo(E, els, used, width)
508}
509
510// The row of the resumes: a mark in the cell of each resume, with its cost when it fits, and the list of the other costs
511function resumePieces(placed: PlacedMarks): Piece[] {
512 const pieces: Piece[] = placed.marks.map((m) => ({ at: m.cell, parts: [{ text: '▲', style: MARK_STYLE }, ...(m.cost === null ? [] : [{ text: ' ' + m.cost, style: {} }])] }))
513 if (placed.list !== null) pieces.push({ at: placed.list.at, parts: [{ text: placed.list.text, style: {} }] })
514 return pieces.sort((a, b) => a.at - b.at)
515}
516
517// The time axis: a label at the first cell of each hour before now, and `now` ending at the last cell of the strip
518function axisPieces(cells: number): Piece[] {
519 const hour = cells / AXIS_HOURS
520 const pieces = Array.from({ length: AXIS_HOURS }, (_, i): Piece => ({ at: i * hour, parts: [{ text: '-' + (AXIS_HOURS - i) + ' h', style: AXIS_STYLE }] }))
521 return [...pieces, { at: cells - 'now'.length, parts: [{ text: 'now', style: AXIS_STYLE }] }]
522}
523
524// The cache history in the columns of the cause table: the strip, the resumes at their cells and the time axis
525function historyGrid(E: Els, d: SessionData, available: number | undefined, surface: string): unknown {
526 const width = HISTORY_COLUMNS[1].width
527 const cells = stripCells(d.requests, d.now)
528 // The state drops old resumes only at the next request, so the strip window decides which resumes count
529 const marks = d.resumes
530 .filter((r) => r.at > d.now - STRIP_MS)
531 .flatMap((r): ResumeMark[] => {
532 const index = stripCellAt(r.at, d.now)
533 return index === null ? [] : [{ cell: index, cost: formatMoney(r.cost) }]
534 })
535 const rows: CellValue[][] = [['cache, last 4 h', stripEls(E, cells, width, 'cache history of the last 4 hours', surface)]]
536 if (marks.length > 0) rows.push(['resumes', offsetEls(E, resumePieces(placeMarks(marks, width)), width, surface)])
537 rows.push(['', offsetEls(E, axisPieces(cells.length), width, surface)])
538 return tableEls(E, HISTORY_COLUMNS, rows, { dimColumns: [0], available, dropOrder: HISTORY_DROP })
539}
540
541export function sessionEls(E: Els, d: SessionData, available?: number, surface: string = 'terminal'): unknown {
542 if (d.rows.length === 0) return text(E, 'No model request in this session yet.', { dimColor: true })
543 // The note explains the two amounts under the main table. It is one Text that wraps to the pane width, and it shows with the reported row
544 const note = d.usd === null ? [] : [text(E, COST_NOTE, { dimColor: true, wrap: 'wrap' })]
545 return E.Box({ flexDirection: 'column', children: [mainTable(E, d, available), ...note, text(E, ' '), causeTable(E, d.causes, available, surface), text(E, ' '), historyGrid(E, d, available, surface)] })
546}
547
548function shareTable(E: Els, title: string, rows: Share[], total: number, available: number | undefined, surface: string): unknown {
549 const body = rows.map((r) => {
550 const part = total > 0 && !r.isUnpriced ? r.cost / total : 0
551 const alt = r.isUnpriced ? UNPRICED : shareAlt(part)
552 return [r.isEstimated ? markedCell(r.name, WEEK_COLUMNS[0]) : r.name, barEls(E, part, BAR_CELLS, WEEK_COLUMNS[1].width, alt, surface), r.isUnpriced ? UNPRICED : formatMoney(r.cost), r.isUnpriced ? '' : share(r.cost, total)]
553 })
554 return tableEls(E, WEEK_COLUMNS, [[title, '', 'cost', 'share'], ...body], { headerRows: 1, available, dropOrder: WEEK_DROP })
555}
556
557function historyAlt(history: HistoryCell[]): string {
558 const percents = history.flatMap((h) => (h.percent === null ? [] : [h.percent]))
559 return 'week history' + (percents.length > 0 ? ', highest ' + Math.round(Math.max(...percents)) + '%' : '')
560}
561
562// The limit, its reset, the projection and the history, as a grid in the columns of the share tables
563function weekHead(E: Els, d: WeekData, available: number | undefined, surface: string): unknown {
564 const [, meter, , value] = WEEK_COLUMNS
565 const rows: CellValue[][] = []
566 if (d.percent === null) {
567 rows.push(['week', 'n/a'])
568 } else {
569 const percent = [text(E, cell(formatPercent(d.percent), value), { bold: true, color: heatText(d.percent / 100) })]
570 rows.push(['week', barEls(E, d.percent / 100, BAR_CELLS, meter.width, 'week ' + Math.round(d.percent) + '% used', surface), '', percent])
571 }
572 if (d.resetAt !== null) rows.push(['resets', dayTime(d.resetAt)])
573 if (d.projection !== '') rows.push(['at the current rate', d.projection])
574 if (d.history.some((h) => h.percent !== null)) {
575 rows.push(['week used, over time', sparkEls(E, d.history, meter.width, historyAlt(d.history), surface)])
576 rows.push(['', dayAxisEls(E, weekDayNames(d.start), meter.width, surface)])
577 }
578 // On a subscription the value of the plan, with an API key the spend: the cost of the week at API prices
579 if (d.total > 0) rows.push(['at API prices', formatMoney(d.total)])
580 return tableEls(E, WEEK_COLUMNS, rows, { dimColumns: [0], dimRows: d.percent === null ? [0] : [], available, dropOrder: WEEK_HEAD_DROP })
581}
582
583export function weekEls(E: Els, d: WeekData, available?: number, surface: string = 'terminal'): unknown {
584 const lines = [weekHead(E, d, available, surface), text(E, ' '), shareTable(E, 'by repo', d.byRepo, d.total, available, surface), text(E, ' '), shareTable(E, 'by model and scope', d.byModelScope, d.total, available, surface)]
585 return E.Box({ flexDirection: 'column', children: lines })
586}
587
588function whyContext(E: Els, b: Breakdown, available: number | undefined, surface: string): unknown {
589 const [, meter, tokens, percent] = WHY_COLUMNS
590 const ratio = b.max > 0 ? b.total / b.max : 0
591 const alt = b.max > 0 ? 'context ' + Math.round(ratio * 100) + '% used' : 'context size unknown'
592 const row: CellValue[] = [
593 b.max > 0 ? 'context of ' + formatTokens(b.max) : 'context',
594 barEls(E, ratio, BAR_CELLS, meter.width, alt, surface),
595 [text(E, cell(formatTokens(b.total), tokens), { bold: true })],
596 b.max > 0 ? [text(E, cell(formatPercent(Math.round(ratio * 100)), percent), { bold: true, color: heatText(ratio) })] : '',
597 ]
598 return tableEls(E, WHY_COLUMNS, [row], { dimColumns: [0], available, dropOrder: WHY_DROP })
599}
600
601function whyCategories(E: Els, b: Breakdown, available: number | undefined, surface: string): unknown {
602 const rows = b.categories.map((c) => {
603 const part = b.total > 0 ? c.tokens / b.total : 0
604 return [c.name, barEls(E, part, BAR_CELLS, WHY_COLUMNS[1].width, shareAlt(part), surface), formatTokens(c.tokens), share(c.tokens, b.total)]
605 })
606 return tableEls(E, WHY_COLUMNS, [['category', '', 'tokens', 'share'], ...rows], { headerRows: 1, available, dropOrder: WHY_DROP })
607}
608
609// A list has no bar: its name column takes the room of the bar column, and the share column goes with the share of the grid
610function whyList(E: Els, title: string, rows: { name: string; tokens: number }[], total: number, columns: Column[]): unknown[] {
611 if (rows.length === 0) return []
612 const body = rows.map((r) => [r.name, formatTokens(r.tokens), share(r.tokens, total)])
613 return [text(E, ' '), tableEls(E, columns, [[title, 'tokens', 'share'], ...body], { headerRows: 1 })]
614}
615
616export function whyEls(E: Els, b: Breakdown | null, available?: number, surface: string = 'terminal'): unknown {
617 if (b === null) return text(E, 'Reading the context breakdown…', { dimColor: true })
618 const [label, meter, tokens, percent] = WHY_COLUMNS
619 const kept = fitColumns(WHY_COLUMNS.map((c) => c.width), WHY_DROP, available)
620 const listColumns = [L(label.width + (kept.includes(1) ? meter.width : 0)), tokens, ...(kept.includes(3) ? [percent] : [])]
621 return E.Box({
622 flexDirection: 'column',
623 children: [
624 whyContext(E, b, available, surface),
625 text(E, ' '),
626 whyCategories(E, b, available, surface),
627 ...whyList(E, 'largest memory files', b.memoryFiles, b.total, listColumns),
628 ...whyList(E, 'MCP servers', b.mcpServers, b.total, listColumns),
629 ...whyList(E, 'custom agents', b.agents, b.total, listColumns),
630 ],
631 })
632}
633
634// Tab 5 is static text: one row for each term of the band and the tabs, drawn as it shows there, and the line that explains it.
635// A term reads as the label that the band or a tab draws. Change a label there and the term here together
636const HELP_TERM_WIDTH = 22
637// The narrowest explanation column. A narrower pane still shows all of the text, in short lines
638const HELP_MIN_TEXT = 12
639// The tube of the first row: 10 cells, 40% of the cache hour left
640const HELP_TUBE_FRACTION = 0.4
641// A fraction inside the range of each stage, for the colour of its word
642const HELP_STAGE_FRACTIONS: Record<Stage, number> = { LIVE: 1, HOT: 0.9, WARM: 0.5, COOLING: 0.2, COLD: 0 }
643
644// How a term is drawn when it is not plain text: the tube of the band (no text), a stage word, the selected row of tab 1, the resume mark of tab 2
645type HelpLook = 'tube' | 'stage' | 'selected' | 'resume'
646type HelpEntry = { term: string; text: string; look?: HelpLook }
647type HelpSection = { title: string; entries: HelpEntry[] }
648
649const HELP: HelpSection[] = [
650 {
651 title: 'Band above the prompt',
652 entries: [
653 { term: '', look: 'tube', text: 'Cache of this conversation. Full after each request, empty when the cache life ends: 1 hour or 5 minutes, read from the cost that Claude Code books. Blue is cold, red is hot.' },
654 { term: 'LIVE', look: 'stage', text: 'A turn runs.' },
655 { term: 'HOT', look: 'stage', text: 'More than 2/3 of the cache life is left.' },
656 { term: 'WARM', look: 'stage', text: '1/3 to 2/3 of the cache life is left.' },
657 { term: 'COOLING', look: 'stage', text: 'Less than 1/3 of the cache life is left.' },
658 { term: 'COLD', look: 'stage', text: 'The cache expired. The next message writes it again.' },
659 { term: '47m left', text: 'Minutes until the cache expires.' },
660 { term: UNKNOWN_LIFE, text: 'The mod has not read the cache life yet. The band shows the time since the last request, no countdown.' },
661 { term: '412k cached', text: 'Tokens in the cache: the context.' },
662 { term: '$8.24 to re-warm', text: 'What the next message costs to write them again.' },
663 { term: 'send now', text: 'The 1-hour cache expires within 10 minutes, and writing it again costs $1 or more.' },
664 { term: '/clear', text: 'The cache is cold, and writing it again costs $1 or more. A new topic is cheaper in a new conversation.' },
665 { term: '/compact', text: 'The context has 400k tokens or more, and every message reads all of it.' },
666 { term: 'slow down', text: 'At the pace so far the week runs out before its reset. A smaller model uses less of it.' },
667 { term: '5h full at 15:31', text: 'At the pace so far the 5-hour limit fills within the hour.' },
668 { term: 'week used up', text: 'Past the weekly limit Claude Code bills usage credits and caches for 5 minutes.' },
669 { term: 'week 41%', text: 'Weekly plan limit used, as Claude Code reports it.' },
670 { term: 'lasts until reset', text: 'At the pace so far the week lasts until its reset.' },
671 { term: 'runs out Fri 14:00', text: 'At the pace so far the week runs out at this time, before its reset.' },
672 { term: '5h 12%', text: '5-hour plan limit used.' },
673 { term: 'today $12.40', text: 'With an API key, in place of the limits: the cost of the sessions on this Mac since midnight.' },
674 { term: '$4.10/h', text: 'With an API key: the cost of the last 60 minutes.' },
675 { term: '[ details ]', text: 'Opens this pane, and [ close ] closes it. In the terminal: ctrl+x tab, then Enter or t.' },
676 { term: '×', text: 'Hides the band in this session. /token-watch band on shows it again. In the terminal: ctrl+x tab, Tab, Enter.' },
677 ],
678 },
679 {
680 title: '1 Now',
681 entries: [
682 { term: 'highlighted row', look: 'selected', text: 'This session.' },
683 { term: 'ctx', text: 'Context tokens of the last request.' },
684 { term: '60 min, today', text: 'Cost in the last 60 minutes and since midnight.' },
685 ],
686 },
687 {
688 title: '2 Session',
689 entries: [
690 { term: 'scope', text: 'main, the type of a subagent, or recommend: the call of /token-watch recommend.' },
691 { term: 'req, input', text: 'Requests, and input tokens outside the cache.' },
692 { term: 'c.write, c.read', text: 'Tokens written to and read from the cache.' },
693 { term: 'estimate', text: 'Total at API prices, from the requests this mod saw.' },
694 { term: 'reported', text: 'The cost that Claude Code reports with /cost.' },
695 { term: 'start', text: 'Cache write of the first request of a thread.' },
696 { term: 'growth', text: 'Cache write of new context in a running thread.' },
697 { term: 'resume', text: 'Cache write after a pause longer than the cache life.' },
698 { term: 'cache, last 4 h', text: 'One cell per 5 minutes. Colour: warm. Dark: cold.' },
699 { term: '▲ $3.37', look: 'resume', text: 'A resume and the cost of its cache write.' },
700 ],
701 },
702 {
703 title: '3 Week',
704 entries: [
705 { term: 'week, resets', text: 'Weekly limit used, and when it resets.' },
706 { term: 'at the current rate', text: 'When the week reaches 100% at the pace so far.' },
707 { term: 'week used, over time', text: 'One cell per 12 hours: the highest weekly percent. Outlines are the periods still to come.' },
708 { term: 'at API prices', text: 'The cost of the week at API prices: on a subscription, what the same use costs with an API key.' },
709 { term: 'by repo, by model', text: 'Cost since the weekly reset.' },
710 ],
711 },
712 {
713 title: '4 Why',
714 entries: [{ term: 'context', text: 'What fills it: categories, memory files, MCP servers and agents, estimated as /context does.' }],
715 },
716 {
717 title: 'Costs',
718 entries: [
719 { term: 'every cost', text: 'An estimate at API list prices. A plan does not bill them. They show where the tokens go.' },
720 { term: 'opus-5-6 ≈', text: 'No exact price yet: priced as the newest model of its family. make price-report lists these models.' },
721 { term: 'unpriced', text: 'The model has no price in the table of the mod.' },
722 ],
723 },
724 {
725 title: '/token-watch',
726 entries: [
727 { term: 'no argument', text: 'Opens this pane, or closes it when it is open.' },
728 { term: 'band off, band on', text: 'Hides or shows the band in all sessions on this Mac. band on also undoes ×. The mod still counts.' },
729 { term: 'recommend', text: 'Asks a model for advice on this usage. A dialog shows the cost first, and the call runs only when you press Ask. On a subscription it counts against the plan allowance.' },
730 ],
731 },
732]
733
734// The elements of a term, in the style of the place where it shows
735function helpTermEls(E: Els, entry: HelpEntry, surface: string): unknown[] {
736 switch (entry.look) {
737 case 'tube': {
738 // The band tube has a frame on the terminal. On the desktop it is an Svg in a Box of its cells, as in a table
739 const onDesktop = isDesktop(E, surface)
740 const tube = tubeEls(E, HELP_TUBE_FRACTION, BAND_TUBE_CELLS, !onDesktop, surface)
741 return onDesktop ? [E.Box({ width: BAND_TUBE_CELLS, flexShrink: 0, children: tube })] : tube
742 }
743 case 'stage':
744 return [text(E, entry.term, { bold: true, color: heatText(HELP_STAGE_FRACTIONS[entry.term as Stage]) })]
745 case 'selected':
746 // The background sits on a Box around the text, as on the selected row of tab 1
747 return [E.Box({ flexShrink: 0, backgroundColor: SELECTED_BACKGROUND, children: [text(E, entry.term, { bold: true })] })]
748 case 'resume': {
749 const [mark, ...rest] = Array.from(entry.term)
750 return [text(E, mark, MARK_STYLE), text(E, rest.join(''))]
751 }
752 default:
753 return [text(E, entry.term)]
754 }
755}
756
757// One row: the term in a column of fixed width, and the explanation in the room that is left. The explanation wraps, so a narrow pane cuts nothing
758function helpRow(E: Els, entry: HelpEntry, room: number | undefined, surface: string): unknown {
759 return E.Box({
760 flexDirection: 'row',
761 children: [
762 E.Box({ width: HELP_TERM_WIDTH, flexShrink: 0, flexDirection: 'row', children: helpTermEls(E, entry, surface) }),
763 E.Box({ flexShrink: 1, ...(room === undefined ? {} : { width: room }), children: [text(E, entry.text, { wrap: 'wrap' })] }),
764 ],
765 })
766}
767
768// Tab 5: the sections with a bold heading each, a blank line between them. The last section names the forms of the command
769export function helpEls(E: Els, available?: number, surface: string = 'terminal'): unknown {
770 const room = typeof available === 'number' && Number.isFinite(available) ? Math.max(HELP_MIN_TEXT, available - HELP_TERM_WIDTH) : undefined
771 const lines = HELP.flatMap((section, i) => [...(i > 0 ? [text(E, ' ')] : []), text(E, section.title, { bold: true }), ...section.entries.map((entry) => helpRow(E, entry, room, surface))])
772 return E.Box({ flexDirection: 'column', children: lines })
773}
774
775export function paneEls(E: Els, tabs: unknown, body: unknown): unknown {
776 return E.Box({ flexDirection: 'column', children: [tabs, text(E, ' '), body] })
777}
778
779// The dialog of /token-watch recommend: the cost before the call, the wait, and the reply.
780// A row has a label column of fixed width and a value that wraps in the room that is left, as the rows of the help tab
781const RECOMMEND_LABEL_WIDTH = 15
782const RECOMMEND_MIN_TEXT = 20
783const RECOMMEND_DATA_NOTE = 'The prompt holds the data of the Session, Week and Why tabs: token counts, costs, plan limits, and the names of repos, memory files, MCP servers and agents. It holds no transcript text, no file content and no prompt text.'
784const RECOMMEND_PLAN_NOTE = 'On a subscription the call counts against the plan allowance.'
785
786export type RecommendActions = { onAsk: () => unknown; onCancel: () => unknown }
787
788function recommendRow(E: Els, label: string, value: unknown[], room: number | undefined): unknown {
789 return E.Box({
790 flexDirection: 'row',
791 children: [
792 E.Box({ width: RECOMMEND_LABEL_WIDTH, flexShrink: 0, children: [text(E, label, { dimColor: true })] }),
793 E.Box({ flexShrink: 1, ...(room === undefined ? {} : { width: room }), children: [E.Text({ wrap: 'wrap', children: value })] }),
794 ],
795 })
796}
797
798// The cost of the call: `$0.04 at API prices of sonnet-5-5`, with ≈ for an estimate. Null for a model without a price
799function recommendCostEls(E: Els, cost: number | null, priceModel: string, isEstimate: boolean): unknown[] {
800 const source = priceSourceOf(priceModel)
801 if (cost === null || source === undefined) return [text(E, 'unknown: the table of the mod has no price for this model')]
802 return [text(E, (isEstimate ? '≈ ' : '') + formatMoney(cost), { bold: true }), text(E, ' at API prices of ' + source + ', with the full output cap')]
803}
804
805// What the call used, under the reply: model, tokens, cost and where the tabs count it
806function recommendUsageEls(E: Els, r: Recommend): unknown[] {
807 if (r.counts === null) return []
808 const c = r.counts
809 const isEstimate = priceInfo(r.priceModel)?.source === 'fallback'
810 const cost = priceInfo(r.priceModel) === undefined ? 'no price' : (isEstimate ? '≈ ' : '') + formatMoney(c.cost)
811 const line = r.model + ' · input ' + formatTokens(c.input + c.cacheRead + c.cacheWrite) + ' · output ' + formatTokens(c.output) + ' · ' + cost + ' at API prices · counted in the Session tab under the scope ' + RECOMMEND_SCOPE
812 return [text(E, ' '), text(E, line, { dimColor: true, wrap: 'wrap' })]
813}
814
815export function recommendEls(E: Els, r: Recommend | null, actions: RecommendActions, available?: number): unknown {
816 if (r === null) return text(E, 'Run /token-watch recommend to ask for recommendations.', { dimColor: true })
817 const room = typeof available === 'number' && Number.isFinite(available) ? Math.max(RECOMMEND_MIN_TEXT, available - RECOMMEND_LABEL_WIDTH) : undefined
818 const cancel = E.Button({ key: 'recommend-cancel', label: 'Cancel', hotkey: 'c', onPress: actions.onCancel })
819 const column = (children: unknown[]) => E.Box({ flexDirection: 'column', children })
820 if (r.phase === 'confirm') {
821 return column([
822 text(E, 'Ask ' + r.model + ' for recommendations on this usage?', { bold: true }),
823 text(E, ' '),
824 recommendRow(E, 'model', [text(E, r.model)], room),
825 recommendRow(E, 'input', [text(E, '≈ ' + formatTokens(r.inputTokens) + ' tokens, estimated from the length of the prompt')], room),
826 recommendRow(E, 'output', [text(E, 'up to ' + formatTokens(r.outputCap) + ' tokens')], room),
827 recommendRow(E, 'highest cost', recommendCostEls(E, r.maxCost, r.priceModel, priceInfo(r.priceModel)?.source === 'fallback'), room),
828 recommendRow(E, 'plan', [text(E, RECOMMEND_PLAN_NOTE)], room),
829 text(E, ' '),
830 text(E, RECOMMEND_DATA_NOTE, { wrap: 'wrap' }),
831 text(E, ' '),
832 E.Box({ flexDirection: 'row', columnGap: 2, children: [E.Button({ key: 'recommend-ask', label: 'Ask ' + r.model, hotkey: 'a', variant: 'primary', onPress: actions.onAsk }), cancel] }),
833 text(E, 'a asks, c or Esc cancels. No call runs before you press Ask.', { dimColor: true }),
834 ])
835 }
836 if (r.phase === 'asking') {
837 return column([text(E, 'Asking ' + r.model + '…', { bold: true }), text(E, 'The reply shows here. The call stops after 2 minutes.', { dimColor: true, wrap: 'wrap' }), text(E, ' '), cancel])
838 }
839 if (r.phase === 'answered') return column([E.Markdown!({ text: r.text }), ...recommendUsageEls(E, r)])
840 return column([text(E, 'No recommendations', { bold: true }), text(E, r.text, { wrap: 'wrap' }), ...recommendUsageEls(E, r)])
841}
842hooks/advice.ts 99 lines1import type { Limit, Main } from '../types'
2import { clockTime, dayTime, formatMoney, formatTokens, fullAt } from './format'
3import { familyOf, priceOf, ratesOf, rewarmCost } from './prices'
4import { TTL_MS } from './temperature'
5
6// The thresholds of the actions
7// send now: the last 10 minutes of a 1-hour cache. A 5-minute cache leaves too little time to act
8export const SEND_NOW_MS = 10 * 60_000
9// send now and /clear: the cache actions show when writing the context again costs this much or more, at API prices
10export const BIG_REWARM_USD = 1
11// /compact: from 400k tokens, 40% of a window of 1M
12export const COMPACT_TOKENS = 400_000
13// 5h full: the 5-hour window reaches 100% within the next 60 minutes
14export const FIVE_HOUR_SOON_MS = 60 * 60_000
15
16const WEEK_MS = 7 * 24 * 3_600_000
17const FIVE_HOUR_MS = 5 * 3_600_000
18
19export type ActionKind = 'weekUsedUp' | 'fiveHour' | 'slowDown' | 'sendNow' | 'clear' | 'compact'
20
21// The one action of the band: the verb in bold, then the rest in the text colour
22export type Action = { kind: ActionKind; verb: string; rest: string }
23
24// The range of the week: it lasts until the reset at the pace so far, or it runs out at a time before the reset
25export type WeekRange = { kind: 'lasts' } | { kind: 'runsOut'; at: number }
26
27export type AdviceInput = { main: Main; limits: readonly Limit[]; limitsAt: number | null; now: number }
28
29function resetOf(limit: Limit): number | null {
30 const at = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
31 return Number.isFinite(at) ? at : null
32}
33
34// The next smaller family for the model hint: Fable, Mythos and Opus to Sonnet, Sonnet to Haiku. Haiku and other models get no hint
35export function smallerFamily(model: string): string | null {
36 const family = familyOf(model)
37 if (family === 'fable' || family === 'mythos' || family === 'opus') return 'Sonnet'
38 return family === 'sonnet' ? 'Haiku' : null
39}
40
41function hint(model: string): string {
42 const smaller = smallerFamily(model)
43 return smaller === null ? '' : ' or use ' + smaller
44}
45
46// The range of the weekly limit, from the pace between the start of the week and the reading. Null without a reset time,
47// without a reading, at 100% or more, after the reset, and when the time of 100% has passed (an old reading)
48export function weekRange(limit: Limit | undefined, readAt: number | null, now: number): WeekRange | null {
49 if (limit === undefined || readAt === null || limit.percentUsed >= 100) return null
50 const resetAt = resetOf(limit)
51 if (resetAt === null || resetAt <= now) return null
52 const at = fullAt(limit.percentUsed, resetAt - WEEK_MS, readAt)
53 if (at === null || at >= resetAt) return { kind: 'lasts' }
54 return at > now ? { kind: 'runsOut', at } : null
55}
56
57// The time when the 5-hour window reaches 100%, when that is before its reset and within the next 60 minutes, or null
58export function fiveHourFullAt(limit: Limit | undefined, readAt: number | null, now: number): number | null {
59 if (limit === undefined || readAt === null) return null
60 const resetAt = resetOf(limit)
61 if (resetAt === null) return null
62 const at = fullAt(limit.percentUsed, resetAt - FIVE_HOUR_MS, readAt)
63 return at !== null && at < resetAt && at > now && at - now <= FIVE_HOUR_SOON_MS ? at : null
64}
65
66// The one action that the band shows, by priority: the limits first, then the cache, then the context. Null for no action.
67// The cache actions need a known cache life and no running turn
68export function actionOf(d: AdviceInput): Action | null {
69 const week = d.limits.find((l) => l.kind === 'seven_day')
70 const fiveHour = d.limits.find((l) => l.kind === 'five_hour')
71 const m = d.main
72 // A reading of 100% counts until its reset; after the reset it is an old reading that the next response replaces
73 const weekResetAt = week === undefined ? null : resetOf(week)
74 if (week !== undefined && week.percentUsed >= 100 && (weekResetAt === null || weekResetAt > d.now)) {
75 const resetAt = weekResetAt
76 return { kind: 'weekUsedUp', verb: 'week used up', rest: ': usage credits' + (resetAt === null ? '' : ' until ' + dayTime(resetAt)) }
77 }
78 const fullAt5h = fiveHourFullAt(fiveHour, d.limitsAt, d.now)
79 if (fullAt5h !== null) return { kind: 'fiveHour', verb: '5h full at ' + clockTime(fullAt5h), rest: ': pause' + hint(m.model) }
80 if (weekRange(week, d.limitsAt, d.now)?.kind === 'runsOut') return { kind: 'slowDown', verb: 'slow down', rest: hint(m.model) }
81 if (m.isWorking) return null
82 const ttl = m.ttl ?? null
83 if (ttl !== null && m.lastRequestAt !== null && priceOf(m.model) !== undefined) {
84 const expiresAt = m.lastRequestAt + TTL_MS[ttl]
85 const rewarm = rewarmCost(m.model, m.contextTokens, ttl)
86 if (rewarm >= BIG_REWARM_USD) {
87 const left = expiresAt - d.now
88 if (ttl === '1h' && left > 0 && left <= SEND_NOW_MS) return { kind: 'sendNow', verb: 'send now', rest: ': after ' + clockTime(expiresAt) + ' the next message costs ' + formatMoney(rewarm) }
89 if (left <= 0) return { kind: 'clear', verb: '/clear', rest: ' if the topic changed' }
90 }
91 }
92 if (m.contextTokens >= COMPACT_TOKENS) {
93 const price = priceOf(m.model)
94 const read = price === undefined ? null : (m.contextTokens * ratesOf(price, m.contextTokens).read) / 1e6
95 return { kind: 'compact', verb: '/compact', rest: ': each message reads ' + formatTokens(m.contextTokens) + (read === null ? '' : ' ≈ ' + formatMoney(read)) }
96 }
97 return null
98}
99types/index.d.ts 125 lines1export type Counts = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6 requests: number
7 cost: number
8}
9
10export type Totals = Record<string, Record<string, Counts>>
11
12export type Hours = Record<string, Record<string, Counts>>
13
14export type Cause = 'start' | 'growth' | 'resume'
15
16export type Causes = Record<Cause, { tokens: number; cost: number }>
17
18export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
19
20// at: the first measure of the percent. seenAt: the last measure that kept the percent within a whole point, when it came after at
21export type Reading = { at: number; kind: string; percentUsed: number; resetsAt?: string; seenAt?: number }
22
23export type Resume = { at: number; cost: number }
24
25// The lifetime of a cache write: 5 minutes or 1 hour
26export type Ttl = '5m' | '1h'
27
28// The lifetime of the cache writes of one scope (main, or a subagent type), read from the cost that Claude Code books for each request.
29// known: the confirmed lifetime, or null before the first match. pending: a match that differs from known. A second match in a row confirms it
30export type Lifetime = { known: Ttl | null; pending: Ttl | null }
31
32// A main request of the last 4 hours, with the lifetime that its cost used
33export type MainRequest = { at: number; ttl: Ttl }
34
35export type Main = {
36 model: string
37 lastRequestAt: number | null
38 // The confirmed lifetime of the cache of the last main request, or null when the mod does not know it
39 ttl: Ttl | null
40 contextTokens: number
41 isWorking: boolean
42 requests: MainRequest[]
43 resumes: Resume[]
44}
45
46export type Run = { sessionId: string; startedAt: number; repo: string }
47
48export type Snapshot = {
49 v: 1
50 key: string
51 sessionId: string
52 repo: string
53 model: string
54 updatedAt: number
55 lastMainRequestAt: number | null
56 // The ttl of Main. A snapshot of an older version has none and counts as unknown
57 mainTtl?: Ttl | null
58 contextTokens: number
59 isWorking: boolean
60 readings: Reading[]
61 hours: Hours
62}
63
64export type BreakdownRow = { name: string; tokens: number }
65
66export type Breakdown = {
67 total: number
68 max: number
69 categories: BreakdownRow[]
70 memoryFiles: BreakdownRow[]
71 mcpServers: BreakdownRow[]
72 agents: BreakdownRow[]
73}
74
75// The dialog of /token-watch recommend. confirm: the cost shows and no call ran. asking: the call runs. answered: the reply shows. failed: the call gave no reply.
76// model is the model as configured, an alias or an id. priceModel is the id that prices the call and that the totals use: `claude-sonnet` for the alias `sonnet`
77export type RecommendPhase = 'confirm' | 'asking' | 'answered' | 'failed'
78
79export type Recommend = {
80 id: number
81 phase: RecommendPhase
82 model: string
83 priceModel: string
84 prompt: string
85 inputTokens: number
86 outputCap: number
87 // The highest cost at API prices, or null for a model without a price
88 maxCost: number | null
89 // The reply as Markdown, or the reason of a failure
90 text: string
91 // The counted usage of the call, after it ran
92 counts: Counts | null
93}
94
95declare module 'claude-code' {
96 interface PluginState {
97 'token-watch': {
98 run: Run | null
99 totals: Totals
100 causes: Causes
101 main: Main
102 limits: Limit[]
103 limitsAt: number | null
104 readings: Reading[]
105 agents: Record<string, string>
106 threads: Record<string, number>
107 // The lifetime of the last request of each thread, so that the gap to the next request is compared with the lifetime of the cache that it can read
108 threadTtls: Record<string, Ttl>
109 // The lifetime of each scope: main, or a subagent type
110 lifetimes: Record<string, Lifetime>
111 hours: Hours
112 tab: number
113 breakdown: Breakdown | null
114 others: Snapshot[]
115 recommend: Recommend | null
116 // The copy of this session of the band setting in the store. The tick reads the store again
117 isBandOn: boolean
118 // The pane of /token-watch is open. The band button reads it for its label
119 isPaneOpen: boolean
120 // The × of the band hid it in this session. /token-watch band on clears it
121 isBandHidden: boolean
122 }
123 }
124}
125