SLOPSHOPPER

token-watch

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

newpanebandcommandtoastmodel
★ 1v0.4.0MITupdated 2026-10-09arviaja/token-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-watch
│ ┃ token-watch ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Now 2: Session 3: Week 4: Why 5: │ ┃ ⏺ Read(src/auth.ts) │ ┃ cache repo ⎿ Read 6 lines │ ┃ app ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /token-watch │ │ 5h 31% [ close ] × ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h 31% [ close ] × ⟨Claude Code's own drawing⟩
Pane · token-watch
1: Now 2: Session 3: Week 4: Why 5: Help cache repo 60 min app $0.00
Pane · token-watch-recommend
Run /token-watch recommend to ask for recommendations.
README

token-watch

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.

Function

Band above the prompt

  • A cache tube for the main conversation of the session. The tube is full right after a request and empties from the hot end over the cache life of that request: 1 hour or 5 minutes (see Cache life). Colours run from blue (cold) to red (hot). Before the mod knows the cache life, the band shows cache life unknown · last request 13m ago in place of the tube, and COLD when 1 hour has passed.
  • The stage: LIVE during a turn, then HOT, WARM, COOLING and COLD, with the minutes left.
  • The context tokens that the cache holds and the cost to write them to the cache again ($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.
  • One action at a time, in bold, when there is something to do. The first that applies:
  • 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.

  • The weekly limit and its range: 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).
  • With an API key, in place of the limits, from the first response on: today $12.40 · $4.10/h, the cost of all sessions on this Mac since midnight and in the last 60 minutes.
  • Every cost is in dollars at API prices. On a subscription the dollars show what the same use costs with an API key.
  • Two buttons at the right end: [ 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.

Cache life

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:

  • Claude Code books each request at the price of its real life: a 5-minute cache write costs 1.25 times the input price, a 1-hour cache write 2 times.
  • After each request the mod compares the rise of the session cost with the cost of the request at the two prices. When exactly one price fits, that is the life of the request. The rise counts from the last reading before the end of the request, so a cost that Claude Code booked before the request started does not count.
  • The first fit sets the life of the main conversation, or of a subagent type. A different life needs two fits in a row, so one rise that fits by chance changes nothing.
  • Claude Code names the life of the main conversation at a model switch, and says on a resume whether the cache expired. The mod uses these facts.
  • Before the first fit, the costs use the default of Claude Code (1 hour for the main conversation, 5 minutes for a subagent), and the band shows no countdown.
  • The cause of a cache write compares the pause with the life of the cache that the previous request left.

Pane

/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.

TabContent
NowEach 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.
SessionThis 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.
WeekA 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.
WhyThe 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.
HelpStatic 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.

Recommendations

/token-watch recommend asks a model for recommendations that lower the token use and the use of the plan allowance.

  • The command opens a dialog first. The dialog shows the model, the estimated input tokens (1 token for every 3 characters of the prompt), the output cap of 4,000 tokens, the highest cost at API prices (the input estimate and the full output cap), and that on a subscription the call counts against the plan allowance.
  • The call runs only when you press Ask. Cancel and Esc close the dialog without a call. Cancel or Esc while the call runs stops the call.
  • The prompt holds the data that the Session, Week and Why tabs show: token counts, costs, plan limits, the cache history and the resumes, the context breakdown, and the names of repos, memory files, MCP servers and custom agents. It also holds the API prices of the price table of the mod. It holds no transcript text, no file content and no prompt text.
  • The call goes through the API client and the credentials of the Claude Code session ($.model.complete): one completion with no history and no tools, at effort medium, with a time limit of 2 minutes.
  • The reply shows in the dialog as Markdown. The mod does not write it into the conversation, so the model of the session does not read it.
  • The mod counts the tokens of the call under the scope 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.
  • The model is the option 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.

Data

  • Each model request comes from the turn.step event, for the main conversation and for each subagent.
  • The plan limits come from Claude Code (session.measure and $.session.usage()). The weekly percent is the figure of Anthropic.
  • Each conversation writes one snapshot into the shared mod store (~/.claude/plugins/store/), at most every 15 seconds. Snapshots older than 8 days are deleted.
  • The band setting is one more key of the same store, settings. Each session reads it at the start and every 15 seconds.
  • After /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.

Hooks

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.

EventWhat the hook does
session.startStarts 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.endWrites the last snapshot to the store.
turn.stepReads 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.completeClears the working flag of the main conversation.
session.measureSaves 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.

Limits

  • The mod sees only sessions that run it. Codex and sessions from before the installation are not counted. There is no backfill from transcripts.
  • Requests that do not pass through turn.step, for example compaction summaries, are not counted. The totals can be lower than /cost.
  • The split by repo, model and scope is an estimate from tokens weighted with API prices. The price table is in 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.
  • A model without a key in the table uses the price of the newest model of the same family (the word after 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.
  • How to update the prices: run 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.
  • The cache life comes from the session cost, so it needs the exact price of the model in the price table. A request fits no life when its model has a fallback price or no price, in fast mode, with US-only inference, with prices of the organization, and when another request books its cost while the request runs. The mod then keeps the last life that it read. A session in which no request fits shows cache life unknown and prices with the default life.
  • A change of the cache life shows from the second request after the change. The first request after the change counts with the old life.
  • A request that neither reads nor writes the cache does not refresh the tube.
  • Each /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.
  • An organization can refuse every mod that calls $.model.complete (a policy mod on plugin.register). There token-watch does not load.
  • Mods are new. Anthropic can turn them off remotely, and the mods API can change between releases.

Requirements

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).

Installation

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.

Turn off, turn on and uninstall

/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:

ActionCommand
Turn offclaude plugin disable token-watch@token-watch
Turn onclaude plugin enable token-watch@token-watch
Uninstallclaude plugin uninstall token-watch@token-watch
Remove the marketplaceclaude 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

Development

make verify runs these checks:

  • a check for em dashes and en dashes in text files
  • a secret scan (gitleaks)
  • a check for private data (scripts/check-private.sh): home paths, and the terms of a local list that is never committed
  • claude 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.

Design

Contributing

See CONTRIBUTING.md. Report a security problem privately, as SECURITY.md describes. PRIVACY.md describes the data that the mod reads, stores and sends.

License

MIT. See LICENSE.

Source 9 files
hooks/register.ts 661 lines
1import { 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}
661
hooks/format.ts 279 lines
1import 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}
279
hooks/prices.ts 141 lines
1import 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}
141
hooks/recommend.ts 263 lines
1import 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}
263
hooks/temperature.ts 225 lines
1import 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}
225
hooks/tally.ts 333 lines
1import 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}
333
hooks/view.ts 842 lines
1import 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}
842
hooks/advice.ts 99 lines
1import 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}
99
types/index.d.ts 125 lines
1export 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