SLOPSHOPPER

usage-panel

The usage panel in a sidebar inside Claude Code: this session, today, the last 30 days, projects, top sessions, the day's costliest turns with why each was…

newpanebandspinnercommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-panel
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ [ Hide ] │ ┃ Usage ⏺ Read(src/auth.ts) │ ┃ Starting the usage panel for this session… ⎿ Read 6 lines │ ┃ The first figures take up to a minute: ⏺ Update(src/auth.ts) │ ┃ ccusage reads every transcript once. ⎿ 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 │ │ › /usage-panel │ │ ⟨Claude Code's own drawing⟩ [ Show usage sidebar ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ Show usage sidebar ]
Pane · Usage · while holding a tool call
[ Hide ] Usage Starting the usage panel for this session… The first figures take up to a minute: ccusage reads every transcript once.
README

Claude Code Cost Sidebar

A live cost and usage panel for Claude Code.

Install. Paste this into a terminal on a Mac:

D="$HOME/claude-code-cost-sidebar"
if [ -d "$D/.git" ]; then git -C "$D" pull --ff-only; else git clone https://github.com/andrewbakercloudscale/claude-code-cost-sidebar.git "$D"; fi \
  && bash "$D/claude-panel-setup.sh"

Then start a new Claude Code session and the sidebar opens by itself. It needs Claude Code 2.1.287 or later, git, jq and Node.js (Requirements). Keep the ~/claude-code-cost-sidebar folder: the sidebar is installed from it. Pasting the same lines again updates it.

Uninstall. One paste removes everything setup installed, then the folder:

curl -fsSL https://raw.githubusercontent.com/andrewbakercloudscale/claude-code-cost-sidebar/main/claude-panel-uninstall.sh | bash \
  && rm -rf "$HOME/claude-code-cost-sidebar"

What it removes, and a dry run that only lists it, are under Uninstall.

Live, always-visible cost and token tracking for Claude Code, as a mod: a sidebar Claude Code itself draws beside the transcript, so you can watch what a coding agent is actually costing you, turn by turn, instead of finding out at the end of the month.

<table> <tr> <td valign="top"><img src="docs/sidebar-top.png" alt="The top of the usage sidebar: this session's model, cost and burn rate, its context as one bar by part against Claude Burst's compaction limit, graphs of context, cost and cache per turn, the turn table with a pauseless compaction's Started and Finished rows, this Mac's proxy state and licence, and Plan Utilisation: how much of the 5-hour and weekly limits is used" width="400"></td> <td valign="top"><img src="docs/sidebar-scrolled.png" alt="The usage sidebar scrolled down: today against a typical day by hour and the 5h block, sessions today and the day's costliest turns, the last 30 days by day and by model, and spend by project" width="400"></td> </tr> <tr> <td align="center"><sub>What a session opens with</sub></td> <td align="center"><sub>Scrolled down</sub></td> </tr> </table>

<sub>Every figure in the screenshots is made up. They are drawn by the mod's own code from invented numbers (docs/render-screenshots.mjs); the whole sidebar in one picture is docs/usage-sidebar.png.</sub>

This came out of a simple problem: AI coding agents burn tokens and money per turn, per session, per day, and none of that is visible while you're working. You only find out later, from a dashboard or an invoice, by which point the expensive session is long over and you've learned nothing you can act on. This repo is the fix: a live panel that sits next to your session and updates every few seconds.

Companion tool: Claude Burst. A local gateway for Claude Code (subscription-first routing with failover, pauseless compaction, session coordination, a dashboard). Each works without the other. Together, the sidebar shows the context Burst really sends, how much of your plan's 5-hour and weekly limits is used (with a warning as one gets close), and marks every pauseless compaction as it happens: see Pauseless compaction.

What needs Claude Burst, and is left out without it:

  • the limit rows in Plan Utilisation and the warning as a limit gets close;
  • the Pauseless Compaction, Compaction Strategies and Overflow to Secondary savings tables;
  • the context bar by part, the compaction line on it, and Uncompacted Size with its Full Async Compaction button;
  • Proxy State, the dashboard button and the * Async Compaction ... * rows in Turns.

Everything else (the session, its turns, today, the week, projects) is read from Claude Code's own transcripts.

The same panel for OpenCode lives in opencode-cost-usage-panel: the two were one repo until they were split apart, which is why the design notes here and there cross-reference each other.

Full write-up and motivation: AI coding costs are guesswork without this: instrumenting OpenCode and Claude Code

What you get

The sidebar: a Claude Code mod (2.1.287 and later)

A mod is a Claude Code plugin that draws inside the session. On a Claude Code that loads mods, setup installs the usage-panel mod (mods/usage-panel), and every new session opens the panel as a sidebar docked to the right of the transcript. Nothing is typed into your terminal, no Accessibility permission is needed and it works in any terminal: Claude Code draws it. Before the mod, the panel was a Ghostty split that a launcher opened by typing keystrokes; that split is still here for older versions (see The panel).

Each section is its own card, most specific first: this session, its turns, this Mac, Plan Utilisation, Pauseless Compaction (with Claude Burst), today across your sessions, sessions today, the last 30 days, your projects. The sidebar scrolls, so the lower cards are a scroll away, and each can be hidden or moved. Reading the sidebar goes through every card.

Command
/show-cost-panelShow the sidebar in this session, beside the transcript.
/hide-cost-panelHide it in this session. New sessions still open it; /usage-panel unpin stops that.
/usage-panelOpen the sidebar (or focus it). Esc puts you back in the prompt.
/usage-panel unpinStop it opening by itself in new sessions.
/usage-panel pinOpen it in every new session again (the default).
/usage-panel hide <section> / show <section>Hide a section, or bring it back. Sections: session, turns, mac, plan, savings, today, sessions, days, projects.
/usage-panel up / down / top / bottom <section>Move a section. The layout is kept for every new session.
/usage-panel sections / resetShow the current order (hidden ones in brackets), or go back to the default.
v (in the sidebar)With Claude Burst installed: open its dashboard, or its support console when the dashboard is down. The same button is in the This Mac card to click.

The sidebar has a Hide button at its top right, which does what /hide-cost-panel does. While it is closed there are two ways back, neither of them in a session that never had a sidebar (the pin is off and nothing opened it):

  • The line under the prompt ends with /show-cost-panel for the usage sidebar. It is text, not a button (that line takes only plain dim text from a mod), so type the command. On a narrow terminal Claude Code cuts it where the row ends.
  • A Show usage sidebar button sits in the band above the prompt and opens the sidebar beside the session. Claude Code lets that band be folded to one line (plugin panel hidden · ctrl+x ctrl+a or click to show, by its [-] or ctrl+x ctrl+a), and the button goes with it, which is why the line under the prompt says the same.

<img src="docs/sidebar-hidden.png" alt="The sidebar hidden, twice. With the band above the prompt open: a Show usage sidebar button over the prompt. With it folded away: the line plugin panel hidden, ctrl+x ctrl+a or click to show. In both the line under the prompt ends with /show-cost-panel for the usage sidebar" width="760">

<sub>Illustration: Claude Code draws the band, the prompt and the line under it; this is a copy of how they look.</sub>

The numbers are the panel's own, not a second implementation: for each session the mod starts ccusage-panel.sh without a terminal (PANEL_HEADLESS=1, via ~/.local/bin/ccusage-panel-mod-start), and it runs its usual two refresh tiers and writes what it would have drawn, as numbers, to ~/.cache/ccusage-panel-cache/mod/<session id>.json. The mod reads that file every 5 seconds. When the session ends the mod stops saying it is there, and the headless panel exits by itself 90 seconds later. The floating alerts over Ghostty still come from it.

While the mod is installed the Ghostty split below stands aside. Set CLAUDE_PANEL_SPLIT=true in the options file to have the split as well, or CLAUDE_PANEL_MOD=no bash claude-panel-setup.sh to install without the mod.

Reading the sidebar

Every dollar figure is tokens counted on this Mac, priced at pay-as-you-go API rates. On a Pro or Max plan that is a measure of use, not a bill. The top line of the sidebar reads live · 14:19, the time the figures were last written; it turns to a red stopped 4m ago when they are more than 3 minutes old.

Colours mean the same thing everywhere: green is normal, yellow is raised, red is high, purple is far out of range, and cyan marks "this one" (this session, this hour, today, this project).

Session

<img src="docs/card-session.png" alt="The Session card" width="460">

On screenWhat it is
*e7d21 · my-appThe last five characters of the session id, and the folder it runs in.
Opus 5.5The model of the latest turn, read from the transcript.
$14.62What this session has cost so far, coloured against your 7-day average session.
$3.10/hrThis session's burn rate.
149 turnsReplies from the model so far.
2.4× avgThis session against your 7-day average session, shown once it has reached half of it.
ctx bar and 86k/1M 9%How full the context is, against the model's whole window. With Claude Burst this is the context Burst really sends, and a thin red line with an arrow (▕◀) on the bar shows where Burst compacts (Auto Compact at 300k underneath), asked from Burst for the folder the session runs in (GetAutoCompactionThreshold), and marked (Intelligent) when Burst's Intelligent Compaction Mode chose it for that repository; that limit is your setting, not the room there is. The label turns yellow as it nears the line (at Burst's warning level) and red past it. Without Burst, see below.
The coloured parts and their keyWith Burst, what the context is made of, largest first: messages, tool results, system tools, MCP tools, memory files (CLAUDE.md and the like), the system prompt. Light grey is Free: the room left in the window. What is used takes its true share of the bar, so a part smaller than one cell is in the key but not on the bar.
Uncompacted Size: 320kClaude Code's own history, which Burst's compaction never shrinks. The gap to the ctx figure is what Burst saves on every turn. Always shown. Green: nothing compacted yet, it is the ctx figure (within a tenth). Yellow: Burst sends a summary and Claude Code holds more than a tenth above it. Red: it holds 300k or more beside a summary, so opening the session again (--resume, --continue) compacts Claude Code's own copy with Burst's summary, in the background and without a summary request; with Hand Burst's summary to Claude Code turned off on Burst's dashboard it stays yellow.
growthContext size, one bar per turn, oldest on the left. Blue, then yellow past 40% of the model's window and red past 70%. A cyan bar is a turn where the context fell to under 60% of the turn before: a compaction.
$/turnCost, one bar per turn. Yellow is over 2× the median turn, red over 4×. The cyan bar is the turn after a compaction (the same turn as the cyan drop in growth): it wrote the smaller context to the cache once, so it costs more by design and is never shown as a warning or reported as a costly turn.
Lines starting ↗ ◇ ▲ ◴ $ !Insights, at most three, only when there is something to say: how many turns until Burst compacts (or, without Burst, until the context turns amber) at the current growth, the median turn, a turn that cost over 4× the median, a session over 3× your average, a cache hit rate under 85%. ◴ is a turn made dear by a pause: it came more than five minutes after the one before, read under half its input from the cache (the cache had expired) and cost at least twice the median. It reads Turn 212 came after a 26m pause and read 4% from cache: $0.90 against a $0.05 median.
A line starting ▤With Claude Burst, one more: it names the part that is half or more of a context of 100k and up, e.g. Tool results are 65% of the context sent.
A limit Burst learned, and a history worth compacting

<table> <tr> <td valign="top"><img src="docs/card-session-intelligent.png" alt="The top of the sidebar with its Hide button, and a Session card whose limit reads Auto Compact at 240k (Intelligent) in cyan" width="400"></td> <td valign="top"><img src="docs/card-session-held.png" alt="A Session card with Uncompacted Size: 539k in red and a Full Async Compaction button beside it" width="400"></td> </tr> <tr> <td align="center"><sub><code>(Intelligent)</code>: Burst chose this limit for the repository</sub></td> <td align="center"><sub><b>Full Async Compaction</b>, from half the window held</sub></td> </tr> </table>

Full Async Compaction is beside Uncompacted Size once Claude Code holds half the model's window or more (500k of 1M). It runs Claude Burst's /compact-async-full: Claude Code's own history is replaced with the summary Burst already wrote, with no summary request and no pause, so Uncompacted Size comes down to what Burst sends. It is Burst's command, from its burst-session mod: where Burst holds no summary of the session yet, or its hand-off is turned off on the dashboard, Burst says so and nothing is compacted.

The context bar without Claude Burst

<img src="docs/card-session-no-burst.png" alt="The Session card without Claude Burst: the ctx bar at 522k, 52% of a 1M window, in amber, ticks named expensive from 400k and wasteful from 700k, and a line saying it is getting expensive, with /compact and /clear" width="460">

Nothing compacts for you, so the bar is a traffic light on the transcript's own context, and it tells you when to act.

Context, as a share of the model's windowOn a 1M windowColourWhat the card says
Under 40%under 400kGreenNothing, or Grows 3k/turn: amber (400k) in ~40 turns.
40% to 70%400k to 700kAmberGetting expensive: every turn re-sends 522k. /compact, or /clear at a break in the work.
Over 70%over 700kRedWasteful: every turn re-sends 760k. /compact now, or /clear and start fresh.
  • The window is the model's, read from the transcript, so on a 200k model the same lines sit at 80k and 140k.
  • The two ticks on the bar are those lines, named underneath in tokens: expensive from 400k, wasteful from 700k.
  • Why it matters: every turn sends the whole context again. Cached, that is cheap per token but not free, and one pause that lets the cache expire re-bills all of it at full price.

Turns

<img src="docs/card-turns.png" alt="The Turns card: the per-turn table with a pauseless compaction's rows" width="460">

The last 12 turns of this session, newest first. Beside the heading, both across every session: Avg API: $0.12 is today's average cost per turn (one turn is one API reply: today's turns and what they cost, read from today's transcripts), and All: $4.10/hr is the burn rate of the current 5h block.

ColumnWhat it is
TurnThe turn's number in this session.
ModelThe model that answered, per turn, from the transcript. A * after it means the turn was served by Claude Burst's secondary provider; its cache and cost cells then show -- and the gateway's own figure, or ?.
CostThat turn at the model's published rates, cache reads and writes included. ? is a model with no known price.
InputThe whole context sent for that turn (input plus cache reads and writes). Coloured by how full the model's window is: yellow past 30%, red past 50%, purple past 70%.
(Δ)What the turn added to the context. Always in k: 0.6k for a rise under a thousand tokens, 0k for one too small to show. When the context shrank by a fifth or more, it is how much went, negative, and the whole row is blue: 64k (-232k) is a compaction. When it rose by far more than the turn wrote, it is the rise, in yellow, over a * Replayed in full: 102k sent again * row: Claude Code sent its whole conversation again, which it does when Anthropic no longer holds the thread (after a pause, say).
CacheThe share of that turn's input read from the prompt cache. Green from 95%, red below, purple below 90%. A low figure straight after a compaction is expected.

<img src="docs/card-turns-replayed.png" alt="The Turns card with turn 118 at 323k (+105k), the rise in yellow, over a row reading Replayed in full: 105k sent again" width="460">

<sub>A turn that sent the whole conversation again.</sub>

A turn that added far more than the session's average, or one past 50% of the window, has its whole row coloured. The * Async Compaction ... * rows are Claude Burst's: see Pauseless compaction.

This Mac

<img src="docs/card-mac.png" alt="The This Mac card" width="460">

On screenWhat it is
Proxy StateWith Claude Burst: where requests are going. PRIMARY (oauth) in green is your subscription; SECONDARY (...) in yellow is the overflow provider; NOT IN USE in red means Burst is installed but out of the path. Absent without Burst.
LicenseThe plan Claude Code is signed in with, or API key when it is metered.
⚠ rowsWith Burst, each of its standing problems (yellow is a warning, red an error), and ⚡ Burst dashboard not answering when it is down.
[ Open Claude Burst dashboard ↗ ]A button: click it, or press v with the sidebar focused.
Red ! rowsThe panel's own errors (a ccusage call that failed, a model it has no price for), so a figure that is missing is explained.

This card sits third so a problem is not under a screen of charts.

Plan Utilisation

<img src="docs/card-plan.png" alt="The Plan Utilisation card: the 5-hour limit at 82% in yellow, the weekly limit at 58%, which projects used each limit, the month's use at API rates beside the plan's price, and when the 5-hour limit will be reached at this pace" width="460">

How close you are to your plan's limits. Anthropic states the figures itself, in headers on every reply; Claude Burst keeps the latest and the sidebar reads them from its dashboard every 15 seconds. They are Anthropic's numbers, not an estimate from token counts.

On screenWhat it is
Max (20x)The plan Claude Code is signed in with.
5h bar, 82% resets 16:17 (1h58m)The share of the 5-hour limit used, and when the window resets. Green, yellow from 80%, red from 95%. Any use at all fills at least one cell. In a narrow sidebar the bar moves to a row of its own under the figures, the full width of the card.
weekly bar, 58% resets Thu 02:00The same for the 7-day limit. Any other window Anthropic reports (a per-model weekly limit, for one) gets a row of its own.
$132 at API rates this month ($200 plan)What this month's use would have cost pay-as-you-go, beside the plan's flat price. Shown for Pro ($20), Max 5x ($100) and Max 20x ($200).
What used the weekly limitThe limit's reading shared out by project: the three projects that spent most since the window opened, and the rest as other, each with its part of the limit in points (a project at 31% of a limit that reads 58% used a little over half of what is gone). This session's project is in cyan and each other project has a colour of its own, the same in both blocks. One block for the 5-hour limit and one for the weekly.
Lines starting ↗ !↗: at the pace of this window so far, the limit is reached before it resets, with when and by how much. !: a limit is used up, and when it comes back.

What used a limit is an estimate. Anthropic says how much of a limit is used, not what used it, and does not publish how it weighs tokens against a limit. The sidebar shares the reading out by each project's cost at API rates in Claude Burst's log over the same window, which is the nearest public measure. Read it as "which project", not as a figure to the point.

A warning before you hit a limit. When a limit passes 80%, and again at 95%, the mod raises a toast in the session: 🟡 Plan limit: 81% of the 5h limit used, resets 16:17 (1h58m), with a yellow mark at 80% and a red one (🔴) at 95%. Once per level and window, in every open session, so it is not repeated each minute.

A warning when one day eats the week. On the weekly limit, a day's fair share is a seventh (14%). When one day uses two days' worth (29%), one toast says so: 🟡 Plan pace: 30% of the weekly limit used today, over 2 days' share (29%). 60% used, resets Tue 04:00. Once a day, from whichever session sees it first. The day starts at the first reading after midnight, so use before any session was open that day is not counted.

<img src="docs/toast-limit.png" alt="The toast: usage-panel, a yellow mark, Plan limit: 82% of the 5h limit used, resets 16:17 (1h58m)" width="380">

<sub>Claude Code draws the toast; this picture copies its look.</sub>

  • The limit rows and the toast need Claude Burst: it is what sees Anthropic's replies. Without it the card shows the plan and the month's use only.
  • There are two limits, 5 hours and 7 days. Anthropic reports no monthly one, so there is no monthly row or warning.
  • A reading is from the last reply on this Mac, in any session, so it is as fresh as your last turn anywhere. The last one is kept, so a session that has not had a reply yet still shows it. A window that has reset is dropped until a reply reports the new one.
  • On an API key there is no plan and no card.

Pauseless Compaction

<img src="docs/card-savings.png" alt="The Pauseless Compaction card: $105 saved over the last 30 days from 38 compactions, then a table with a column for the 30 days and one for the last 7: compactions, what was not re-sent, the summaries, the cache rewrites, net savings, the saving per compaction and its share of all spend" width="460">

What Claude Burst's pauseless compaction has saved, across every session on this Mac. Shown only with Burst, once it has compacted something. The figures are Burst's own, read from its dashboard once a minute.

A table, not a chart: one column for Burst's window and, where that is longer than a week, one for the last 7 days (summed from Burst's daily figures). Burst's window is 30 days from 0.20.12; with an older Burst it is 7 days and the table has one column.

On screenWhat it is
30 days, 7 daysThe column headings: the window Burst keeps these figures over, and the last 7 days of it.
$105 savedThe net saving over the window: blue when compaction has paid for itself, red (lost) when it has not yet.
Blue and red figuresBlue is a saving: Not re-sent, Net savings, Each, Bill cut by. The same figure is red, with its minus, when it is a loss. Counts, prices and what the saving cost (Summaries, Cache rewrites, Secondary paid) have no colour.
CompactionsHow many times Burst compacted.
Not re-sentWhat the turns after each compaction would have cost with the full history still in the context.
SummariesWhat the background calls that wrote the summaries cost.
Cache rewritesEach compaction changes the context, so the next turn writes it to the cache once at the higher rate.
Net savingsNot re-sent, less the other two. Red with a minus when it is a loss.

| Each | Net savings per compacti

Source 1 files
hooks/register.js 1905 lines
1// The usage panel in a sidebar inside Claude Code.
2//
3// The numbers are the panel's own: ccusage-panel.sh runs headless for this
4// session (started by ccusage-panel-mod-start) and writes everything it would
5// have drawn in its Ghostty split, as numbers, to
6// ~/.cache/ccusage-panel-cache/mod/<session id>.json. This mod reads that file
7// and draws it: the same figures and traffic lights, plus graphs and insights.
8// Nothing is computed twice and no keystrokes are typed anywhere.
9//
10// With Claude Burst on this Mac (the panel's JSON names its dashboard), the
11// session's one ctx bar is Burst's: the context it really sends, by part,
12// against its compaction limit. Burst's standing problems sit in This Mac.
13//
14// Also with Burst: Plan Utilisation, the share of the plan's 5-hour and weekly
15// limits used, from the limit headers on Anthropic's replies (Burst keeps the
16// latest for about a minute), with a toast when a limit is close.
17//
18// /usage-panel opens or focuses the sidebar; /usage-panel pin opens it in
19// every new session (the default), /usage-panel unpin stops that. (/usage
20// itself is Claude Code's own.)
21
22const PANE = 'usage'
23const COMMAND = 'usage-panel'
24const SHOW_COMMAND = 'show-cost-panel'
25const HIDE_COMMAND = 'hide-cost-panel'
26const COLUMNS = 58 // the sidebar's width to start with; dragging it wins
27const READ_MS = 5000 // the panel writes every 10s; reading at 5s halves the lag
28const FEED_MS = 30000 // keeps the headless panel alive; it stops after 90s without
29const STALE_S = 180 // older than this, the feed has stopped
30const LIMITS_MS = 15000 // the plan's limits: Burst keeps only its last 20 replies
31const EXTRA_MS = 60000 // Burst's slower figures
32const LIMITS_KEY = 'limits'
33// The context without Claude Burst, as a share of the model's window: green
34// under 40%, amber to 70%, red beyond (400k and 700k of a 1M window). Every
35// turn re-sends the whole context, so past these it is time to /compact or
36// /clear by hand.
37const CTX_AMBER = 0.4
38const CTX_RED = 0.7
39const LIMIT_WARN = 0.8 // a toast when a plan limit passes this, and again at
40const LIMIT_ALARM = 0.95
41const DAY_KEY = 'weekday' // the store's key for today's share of the weekly limit, shared by every session
42const PAUSE = 300 // seconds idle after which the prompt cache has expired
43const DAY_SHARES = 2 // a toast when one day uses this many days' worth of the weekly limit
44const PIN_KEY = 'pinned'
45const LAYOUT_KEY = 'layout'
46// The sidebar's sections, most specific first. This Mac (proxy state,
47// licence, Burst's standing problems, its dashboard button) is third, under
48// the turn table: a problem there must not sit below a screen of charts.
49// Plan Utilisation is next: how close the plan's limits are. Then, with
50// Claude Burst, what its pauseless compaction has saved.
51export const SECTIONS = ['session', 'turns', 'mac', 'plan', 'savings', 'today', 'sessions', 'days', 'projects']
52
53let sid = ''
54let home = ''
55let cwd = ''
56let data = null // the panel's last mod/<sid>.json
57let raw = ''
58let feedError = ''
59let pinned = true
60// The sidebar has been open in this session: where it is then closed, a
61// button above the prompt brings it back. A session that never had it (the
62// pin is off) is left without one.
63let wanted = false
64// The sidebar is drawn: kept here as it opens and closes, since the button
65// is drawn from it and a drawing asks the engine nothing.
66let up = false
67// It was opened and waits undrawn, on a terminal too narrow for a sidebar
68// nobody asked for: it is up once it is first drawn.
69let waiting = false
70let layout = null // { order, hidden } as the person left it
71let burst = null // { down, mod }: Burst's /api/mod answer; null without Burst
72let burstRaw = ''
73let limits = null // [{ key, util, reset }]: the plan's limits, as Anthropic last reported them
74let extra = null // { saved, secondary, warn, comp, auto }: Burst's slower figures, the percent of its limit it warns at, and this folder's compaction limit
75const warned = {} // limit window and reset time -> the level already toasted
76
77// A stored layout made whole: unknown names dropped, sections added since
78// it was saved put back in their default place.
79export function layoutOf(l) {
80  const order = (l && Array.isArray(l.order) ? l.order : []).filter((x) => SECTIONS.includes(x))
81  for (const x of SECTIONS) {
82    if (order.includes(x)) continue
83    const before = SECTIONS.slice(0, SECTIONS.indexOf(x)).reverse().find((y) => order.includes(y))
84    order.splice(before ? order.indexOf(before) + 1 : 0, 0, x)
85  }
86  const hidden = (l && Array.isArray(l.hidden) ? l.hidden : []).filter((x) => SECTIONS.includes(x))
87  return { order, hidden: [...new Set(hidden)] }
88}
89
90// "/usage-panel hide today", "show", "up", "down", "top", "bottom", "reset":
91// the new layout, or a string saying what was wrong.
92export function relayout(l, verb, name) {
93  const lay = layoutOf(l)
94  if (verb === 'reset') return layoutOf(null)
95  if (!SECTIONS.includes(name)) return 'Sections: ' + SECTIONS.join(', ')
96  const i = lay.order.indexOf(name)
97  const move = (j) => { lay.order.splice(i, 1); lay.order.splice(Math.max(0, Math.min(lay.order.length, j)), 0, name) }
98  if (verb === 'hide') lay.hidden = [...new Set([...lay.hidden, name])]
99  else if (verb === 'show') lay.hidden = lay.hidden.filter((x) => x !== name)
100  else if (verb === 'up') move(i - 1)
101  else if (verb === 'down') move(i + 1)
102  else if (verb === 'top') move(0)
103  else if (verb === 'bottom') move(SECTIONS.length)
104  return lay
105}
106
107export function register(on) {
108  on('session.start', async ($, e, next) => {
109    sid = await $.session.id()
110    home = (await $.env.get('HOME')) || ''
111    cwd = (e && e.cwd) || (await $.session.cwd()) || home
112    try {
113      layout = (await $.store.get(LAYOUT_KEY)) || null
114    } catch (err) {
115      // No stored layout: the default.
116    }
117    try {
118      const v = await $.store.get(PIN_KEY)
119      if (v === false) pinned = false
120    } catch (err) {
121      // No stored choice yet: pinned.
122    }
123    await feed($)
124    await read($)
125    await readBurst($)
126    await readLimits($)
127    await readExtras($)
128    $.clock.every(FEED_MS, async () => { await feed($) })
129    $.clock.every(LIMITS_MS, async () => { if (await readLimits($)) $.ui.invalidate('ui.render') })
130    $.clock.every(EXTRA_MS, async () => { if (await readExtras($)) $.ui.invalidate('ui.render') })
131    $.clock.every(READ_MS, async () => {
132      const changed = await read($)
133      if ((await readBurst($)) || changed) $.ui.invalidate('ui.render')
134    })
135    try {
136      await $.command.register({ name: COMMAND, description: 'The usage sidebar: open it; pin / unpin; hide, show, up, down, top, bottom <section>; sections; reset', immediate: true })
137    } catch (err) {
138      $.ui.log('could not add /' + COMMAND + ': ' + err)
139    }
140    // The two things people look for first, under names that say them.
141    try {
142      await $.command.register({ name: SHOW_COMMAND, description: 'Show the cost and usage sidebar in this session', immediate: true })
143      await $.command.register({ name: HIDE_COMMAND, description: 'Hide the cost and usage sidebar in this session', immediate: true })
144    } catch (err) {
145      $.ui.log('could not add /' + SHOW_COMMAND + ' and /' + HIDE_COMMAND + ': ' + err)
146    }
147    if (pinned) {
148      wanted = true
149      try {
150        up = seat(await $.ui.open({ id: PANE, title: 'Usage', columns: COLUMNS }))
151      } catch (err) {
152        $.ui.log('could not open the usage sidebar: ' + err)
153      }
154    }
155    return next(e)
156  })
157
158  // Closed by the person's own close mark or key too: the button above the
159  // prompt is drawn from whether the sidebar is up.
160  // The close itself is never held up by it.
161  on('ui.close', ($, e, next) => {
162    const out = next(e)
163    if (e && e.id === PANE) {
164      Promise.resolve(out).then(() => {
165        up = false
166        waiting = false
167        $.ui.invalidate('ui.render')
168      }).catch(() => {})
169    }
170    return out
171  })
172
173  // The way back, on the line under the prompt: the band above it can be
174  // folded away by the person, and the button below with it.
175  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
176    if (!wanted || up) return next(e)
177    const before = e.props && e.props.tail ? e.props.tail + ' · ' : ''
178    return next({ ...e, props: { ...e.props, tail: before + '/' + SHOW_COMMAND + ' for the usage sidebar' } })
179  })
180
181  // While the sidebar is closed, or waits undrawn on a terminal too narrow
182  // for one nobody asked for: one button, under whatever else is there.
183  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
184    const theirs = await next(e)
185    if (!wanted || up || (e.props && e.props.hasSurvey)) return theirs
186    const { Box, Button } = $.ui.resolve(e)
187    const ours = Box({ flexDirection: 'row', children: [Button({ key: 'usage-show', label: 'Show usage sidebar', onPress: () => showPane($) })] })
188    return theirs ? Box({ flexDirection: 'column', children: [theirs, ours] }) : ours
189  })
190
191  on('command.run', { command: SHOW_COMMAND }, async ($) => {
192    await showPane($)
193    return {}
194  })
195
196  on('command.run', { command: HIDE_COMMAND }, async ($) => {
197    await hidePane($)
198    return {}
199  })
200
201  on('command.run', { command: COMMAND }, async ($, e) => {
202    const arg = String((e && e.args) || '').trim().toLowerCase()
203    if (arg === 'pin' || arg === 'unpin') {
204      pinned = arg === 'pin'
205      try { await $.store.set(PIN_KEY, pinned) } catch (err) { $.ui.log('could not save the pin: ' + err) }
206      $.ui.toast(pinned ? 'Usage panel opens in every new session' : 'Usage panel opens only with /' + COMMAND)
207      if (pinned) await showPane($)
208      return {}
209    }
210    const [verb, name] = arg.split(/\s+/)
211    if (['hide', 'show', 'up', 'down', 'top', 'bottom', 'reset'].includes(verb)) {
212      const next = relayout(layout, verb, name)
213      if (typeof next === 'string') {
214        $.ui.toast(next)
215        return {}
216      }
217      layout = next
218      try { await $.store.set(LAYOUT_KEY, layout) } catch (err) { $.ui.log('could not save the layout: ' + err) }
219      $.ui.invalidate('ui.render')
220      return {}
221    }
222    if (verb === 'sections') {
223      const lay = layoutOf(layout)
224      $.ui.toast(lay.order.map((x) => (lay.hidden.includes(x) ? '(' + x + ')' : x)).join(' · '))
225      return {}
226    }
227    wanted = true
228    up = seat(await $.ui.open({ id: PANE, title: 'Usage', columns: COLUMNS, focus: true, closeOnEscape: true }))
229    $.ui.invalidate('ui.render')
230    return {}
231  })
232
233  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
234    if (e.requestId !== PANE) return next(e)
235    // One that waited for a wider terminal has its place.
236    if (waiting) {
237      waiting = false
238      up = true
239      $.ui.invalidate('ui.render')
240    }
241    const { Box, Text, Button } = $.ui.resolve(e)
242    const width = Math.max(30, Math.min(100, (e.props && e.props.bodyColumns) || (e.viewport && e.viewport.columns) || 50))
243    const now = Math.floor((await $.clock.now()) / 1000)
244    const extras = []
245    // Claude Burst's dashboard, as the pane's [View] was: the dashboard when
246    // it answers, else Burst's support console, which is up when it is not.
247    const b = data && data.burst
248    if (b && b.dashboard) {
249      extras.push(Button({
250        // Not plain: Claude Code draws it as a button, so it reads as one.
251        key: 'burst', label: 'Open Claude Burst dashboard ↗', hotkey: 'v',
252        onPress: async () => {
253          let url = b.dashboard
254          try {
255            const r = await $.http.fetch(b.dashboard)
256            if (!r.ok && b.console) url = b.console
257          } catch (err) {
258            if (b.console) url = b.console
259          }
260          try { await $.process.run(['open', url]) } catch (err) { $.ui.toast('could not open ' + url) }
261        },
262      }))
263    }
264    // Beside Uncompacted Size, where Claude Code holds half the window or
265    // more: Burst's /compact-async-full, pressed.
266    const full = Button({ key: 'usage-full-compact', label: 'Full Async Compaction', onPress: () => fullCompact($) })
267    const rows = panel(Box, Text, data, width, now, feedError, layout, extras, burst, limits, extra, full)
268    // At the top right, where a close mark is looked for. The button above
269    // the prompt brings the sidebar back.
270    const top = Box({ flexDirection: 'row', justifyContent: 'flex-end', children: [Button({ key: 'usage-hide', label: 'Hide', role: 'dismiss', onPress: () => hidePane($) })] })
271    return Box({ flexDirection: 'column', children: [top, ...rows] })
272  })
273}
274
275// Opens the sidebar beside the session, where a new session opens it: no
276// focus taken. The button above the prompt goes as it does.
277async function showPane($) {
278  wanted = true
279  try { up = seat(await $.ui.open({ id: PANE, title: 'Usage', columns: COLUMNS })) } catch (err) { $.ui.toast('could not open the usage sidebar') }
280  $.ui.invalidate('ui.render')
281}
282
283// Burst's /compact-async-full: Claude Code's own history replaced with the
284// summary Burst already wrote, no summary request and no pause. The command
285// is Burst's mod's (burst-session), and says for itself when it has nothing
286// to hand over.
287const FULL_COMMAND = 'compact-async-full'
288async function fullCompact($) {
289  try {
290    await $.command.run({ command: FULL_COMMAND, args: '' })
291  } catch (err) {
292    $.ui.toast('/' + FULL_COMMAND + ' did not run: it is Claude Burst\'s mod\'s command (' + err + ')')
293  }
294}
295
296// Whether the sidebar is drawn, from what $.ui.open answered: not when it
297// waits undrawn, on a terminal too narrow for one nobody asked for.
298function seat(opened) {
299  waiting = !!(opened && opened.isPlaced === false)
300  return !waiting
301}
302
303// Closes it, for this session only: /usage-panel unpin stops it opening in
304// new ones.
305async function hidePane($) {
306  // Hidden by hand, so it was had: the button above the prompt is the way back.
307  wanted = true
308  try {
309    await $.ui.close({ id: PANE })
310    up = false
311    waiting = false
312  } catch (err) {
313    $.ui.log('could not close the usage sidebar: ' + err)
314  }
315  $.ui.invalidate('ui.render')
316}
317
318// Starts the headless panel when it is not running, and says this session
319// still wants it.
320async function feed($) {
321  if (!sid || !home) return
322  try {
323    const r = await $.process.run([home + '/.local/bin/ccusage-panel-mod-start', sid, cwd], { timeoutMs: 10000 })
324    feedError = r.exitCode === 0 ? '' : String(r.stderr || 'ccusage-panel-mod-start exited ' + r.exitCode).trim()
325  } catch (err) {
326    feedError = 'ccusage-panel-mod-start: ' + err
327  }
328}
329
330// True when the file changed.
331async function read($) {
332  if (!sid || !home) return false
333  let text = ''
334  try {
335    text = await $.fs.read(home + '/.cache/ccusage-panel-cache/mod/' + sid + '.json')
336  } catch (err) {
337    return false
338  }
339  if (text === raw) return false
340  raw = text
341  try {
342    data = JSON.parse(text)
343  } catch (err) {
344    return false
345  }
346  return true
347}
348
349// Burst's own account of this session, from its dashboard. True when what
350// the sidebar draws of it changed. Alerts are not asked for (since = now):
351// they are the burst-session mod's to show.
352async function readBurst($) {
353  const b = data && data.burst
354  if (!b || !b.dashboard || !sid) {
355    const had = burst !== null
356    burst = null
357    burstRaw = ''
358    return had
359  }
360  let next = { down: true, mod: null }
361  try {
362    const since = Math.floor((await $.clock.now()) / 1000)
363    const r = await $.http.fetch(b.dashboard.replace(/\/+$/, '') + '/api/mod?session=' + encodeURIComponent(sid) + '&since=' + since)
364    if (r.ok) {
365      const m = JSON.parse(r.text)
366      next = { down: false, mod: { route: m.route, overflow: m.overflow, primary_failing: m.primary_failing, session: m.session, problems: m.problems, handoff: await handoffOn($) } }
367    }
368  } catch (err) {
369    // Not answering, or not Burst's answer: drawn as down.
370  }
371  const text = JSON.stringify(next)
372  if (text === burstRaw) return false
373  burstRaw = text
374  burst = next
375  return true
376}
377
378// Whether Burst's mod hands Burst's summary to Claude Code: on unless the
379// dashboard's option is off. Read from Burst's own file, as its mod does;
380// /api/mod does not carry it.
381async function handoffOn($) {
382  try {
383    return JSON.parse(await $.fs.read(home + '/.config/claude-burst/mod.json')).handoff !== false
384  } catch (err) {
385    return true
386  }
387}
388
389// The plan's limits: the limit headers on Anthropic's latest reply. Burst
390// keeps the headers of its last 20 replies of any kind, heartbeats included,
391// so a model reply is in the list for about a minute and less with several
392// sessions open. Hence every 15 seconds, and the last reading is kept in the
393// store: a session that has not had a reply yet, or missed one, shows the
394// reading another session took. True when what the sidebar draws changed.
395// A limit that has passed 80% or 95% is toasted, once per level and window.
396async function readLimits($) {
397  const b = data && data.burst
398  if (!b || !b.dashboard || !sid) {
399    const had = limits !== null
400    limits = null
401    return had
402  }
403  const before = JSON.stringify(limits)
404  const now = Math.floor((await $.clock.now()) / 1000)
405  let found = null
406  try {
407    const r = await $.http.fetch(b.dashboard.replace(/\/+$/, '') + '/api/responses')
408    if (r.ok) found = limitsOf(JSON.parse(r.text))
409  } catch (err) {
410    found = null
411  }
412  if (found) {
413    limits = found
414    if (JSON.stringify(found) !== before) {
415      try { await $.store.set(LIMITS_KEY, found) } catch (err) { $.ui.log('could not save the plan limits: ' + err) }
416    }
417    const week = found.find((l) => l.key === '7d' && l.reset > now)
418    if (week) await dayPace($, week, now)
419  } else if (limits === null) {
420    try {
421      const kept = await $.store.get(LIMITS_KEY)
422      if (Array.isArray(kept) && kept.every((l) => l && typeof l.key === 'string' && typeof l.util === 'number')) limits = kept
423    } catch (err) {
424      limits = null
425    }
426  }
427  for (const l of limits || []) {
428    if (!(l.reset > now)) continue
429    const level = l.util >= LIMIT_ALARM ? 2 : l.util >= LIMIT_WARN ? 1 : 0
430    const key = l.key + ':' + l.reset
431    if (level > (warned[key] || 0)) {
432      warned[key] = level
433      limitToast($, l, now)
434    }
435  }
436  return JSON.stringify(limits) !== before
437}
438
439// Burst's slower figures, once a minute: what compaction has saved this
440// session, and what went to the secondary today. True when they changed.
441async function readExtras($) {
442  const b = data && data.burst
443  if (!b || !b.dashboard || !sid) {
444    const had = extra !== null
445    extra = null
446    return had
447  }
448  const base = b.dashboard.replace(/\/+$/, '')
449  const get = async (path) => {
450    try {
451      const r = await $.http.fetch(base + path)
452      return r.ok ? JSON.parse(r.text) : null
453    } catch (err) {
454      return null
455    }
456  }
457  const before = JSON.stringify(extra)
458  const now = Math.floor((await $.clock.now()) / 1000)
459  const state = await get('/api/state')
460  const next = { saved: null, secondary: null, warn: 0, comp: null, auto: null, over: null, by: null, week: null }
461  // Where Burst compacts sessions in this folder: one fixed size, the
462  // person's own for the repository, or the one Intelligent Compaction Mode
463  // has learned for it.
464  const at = cwd ? await get('/api/GetAutoCompactionThreshold?folder=' + encodeURIComponent(cwd)) : null
465  if (at && typeof at.threshold === 'number' && typeof at.source === 'string') {
466    const f = at.failures || {}
467    next.auto = { at: at.threshold, source: at.source, fixed: at.fixed || 0, target: at.target || 0, delay: at.delay_minutes || 0, buffer: at.buffer_percent || 0, lost: (f.unpaid || 0) + (f.summary_failed || 0) + (f.unused || 0), attempts: f.attempts || 0 }
468  }
469  const cfg = state && state.context && state.context.compaction
470  if (cfg && cfg.warn_at_percent > 0) next.warn = cfg.warn_at_percent
471  const iso = (sec) => new Date(sec * 1000).toISOString().slice(0, 19) + 'Z'
472  const stats = state && state.context && state.context.compaction_stats
473  const mine = ((stats && stats.sessions) || []).filter((x) => x && x.session === sid)
474  // Every session Burst has compacted, over its window (30 days; 7 before
475  // Burst 0.20.12).
476  if (stats && stats.compactions > 0) {
477    next.comp = {
478      days: (state.context.window_days > 0 && state.context.window_days) || 7,
479      n: stats.compactions, saved: stats.saved_usd || 0, summary: stats.summary_usd || 0, rewrite: stats.rewrite_usd || 0,
480      net: stats.net_usd || 0, tokens: stats.tokens_not_resent || 0, before: stats.largest_before || 0, after: stats.largest_after || 0,
481      daily: (Array.isArray(stats.daily) ? stats.daily : []).filter((x) => x && typeof x.date === 'string').map((x) => ({ d: x.date, net: x.net_usd || 0, n: x.compactions || 0, saved: x.saved_usd || 0, summary: x.summary_usd || 0, rewrite: x.rewrite_usd || 0 })),
482    }
483  }
484  // The same requests replayed three ways: Claude Code alone (compacts at
485  // default_at), a fixed Compact at (fixed_at), and Burst as it ran. Context
486  // cost only: cache reads plus what the compactions cost.
487  const strat = state && state.context && state.context.compaction_strategies
488  const way = (x) => ({ usd: (x && x.usd) || 0, n: (x && x.compactions) || 0 })
489  if (strat && strat.default && strat.fixed && strat.actual && strat.requests > 0) {
490    next.strat = {
491      days: (state.context.window_days > 0 && state.context.window_days) || 7,
492      defaultAt: strat.default_at || 0, fixedAt: strat.fixed_at || 0,
493      def: way(strat.default), fixed: way(strat.fixed), actual: way(strat.actual),
494      // The cheapest of every fixed size Burst replayed (newer Bursts only).
495      best: strat.cheapest && strat.cheapest.at > 0 && strat.cheapest.usd > 0 ? { at: strat.cheapest.at, ...way(strat.cheapest) } : null,
496      // The replay checked against the bill, since the sizes in force went
497      // on record: what it predicted they would cost, and what they did.
498      // Not under 200 requests, where one compaction is most of the bill.
499      track: strat.track && strat.track.requests >= 200 && strat.track.planned && strat.track.planned.usd > 0
500        ? { since: String(strat.track.since || ''), planned: way(strat.track.planned), actual: way(strat.track.actual), fixed: way(strat.track.fixed) } : null,
501      daily: (Array.isArray(strat.daily) ? strat.daily : []).filter((x) => x && typeof x.date === 'string').map((x) => {
502        const d = way(x.default), f = way(x.fixed), a = way(x.actual)
503        return { d: x.date, defUsd: d.usd, defN: d.n, fixedUsd: f.usd, fixedN: f.n, actualUsd: a.usd, actualN: a.n }
504      }),
505    }
506  }
507  // What the secondary's requests would have cost at the price of the model
508  // Claude Code asked for, against what the secondary charged.
509  const over = state && state.context && state.context.overflow_stats
510  if (over && over.requests > 0) {
511    next.over = {
512      days: (state.context.window_days > 0 && state.context.window_days) || 7,
513      n: over.requests, priced: over.priced || 0, list: over.list_usd || 0, paid: over.paid_usd || 0, saved: over.saved_usd || 0,
514      daily: (Array.isArray(over.daily) ? over.daily : []).filter((x) => x && typeof x.date === 'string').map((x) => ({ d: x.date, net: x.saved_usd || 0, n: x.requests || 0, list: x.list_usd || 0, paid: x.paid_usd || 0 })),
515    }
516  }
517  // What was spent over the same days, every provider, to say what share of
518  // it each saving is.
519  // The same for the last 7 of them where the window is longer.
520  const days = (next.comp && next.comp.daily) || (next.over && next.over.daily) || []
521  const spentSince = async (day) => {
522    const all = day ? await get('/api/usage?range=custom&limit=1&from=' + day.d + '&to=' + iso(now)) : null
523    return ((all && all.by_provider) || []).reduce((a, x) => a + ((x && x.usd) || 0), 0)
524  }
525  const spent = await spentSince(days[0])
526  if (spent > 0) next.spent = spent
527  if (days.length > 7) {
528    const spent7 = await spentSince(days[days.length - 7])
529    if (spent7 > 0) next.spent7 = spent7
530  }
531  if (mine.length > 0) next.saved = { net: mine.reduce((a, x) => a + (x.net_usd || 0), 0), n: mine.reduce((a, x) => a + (x.compactions || 0), 0) }
532  if (state && state.today && state.today.SecondaryRequests > 0) {
533    const day = new Date(now * 1000)
534    const p2 = (n) => String(n).padStart(2, '0')
535    const ymd = day.getFullYear() + '-' + p2(day.getMonth() + 1) + '-' + p2(day.getDate())
536    const usage = await get('/api/usage?range=custom&limit=1&from=' + ymd + '&to=' + ymd + 'T23:59')
537    const others = ((usage && usage.by_provider) || []).filter((x) => x && x.key !== 'anthropic' && x.requests > 0)
538    if (others.length > 0) next.secondary = { requests: others.reduce((a, x) => a + x.requests, 0), usd: others.reduce((a, x) => a + (x.usd || 0), 0), names: others.map((x) => x.key) }
539  }
540  // What used each of the plan's limits, by project: Anthropic's spend in
541  // Burst's log since the window opened. Only the plain windows (5h, 7d): a
542  // per-model one is a part of the weekly.
543  const byRepo = (u) => {
544    const rows = ((u && u.by_repo) || []).filter((x) => x && x.usd > 0).map((x) => ({ name: x.key || 'no folder', usd: x.usd })).sort((a, b) => b.usd - a.usd)
545    return rows.length > 0 ? { rows: rows.slice(0, 8), total: rows.reduce((a, x) => a + x.usd, 0) } : null
546  }
547  for (const l of limits || []) {
548    const len = windowSeconds(l.key)
549    if (!/^\d+[hd]$/.test(l.key) || !len || !(l.reset > now) || !(l.util > 0)) continue
550    const found = byRepo(await get('/api/usage?range=custom&limit=1&provider=anthropic&from=' + iso(l.reset - len) + '&to=' + iso(now)))
551    if (found) next.by = { ...(next.by || {}), [l.key]: found }
552  }
553  // And the last 7 days by project, to say when the project that leads the
554  // 30 days is no longer the one that leads the week. With the names Burst
555  // knows, so a project it names differently is not taken for a new one.
556  const week = await get('/api/usage?range=7d&limit=1&provider=anthropic')
557  const found = byRepo(week)
558  if (found) next.week = { ...found, repos: ((week.options && week.options.repos) || []).filter((x) => typeof x === 'string') }
559  extra = next.saved || next.secondary || next.warn || next.comp || next.strat || next.auto || next.over || next.by || next.week ? next : null
560  return JSON.stringify(extra) !== before
561}
562
563// The warning for a limit that is close: longer on screen from 95%.
564function limitToast($, l, now) {
565  // A toast is one colour, the engine's: the level is a coloured mark in
566  // the text, yellow at the warning and red at the alarm.
567  $.ui.toast((l.util >= LIMIT_ALARM ? '🔴 ' : '🟡 ') + 'Plan limit: ' + Math.round(l.util * 100) + '% of the ' + limitName(l.key) + ' limit used, resets ' + when(l.reset, now), { timeoutMs: l.util >= LIMIT_ALARM ? 30000 : 15000 })
568}
569
570// The plan's limits from Burst's list of recent replies: the newest reply
571// that carries Anthropic's utilisation headers, one row per window (5h, 7d,
572// and any other it names), shortest window first. null when none does.
573export function limitsOf(responses) {
574  if (!Array.isArray(responses)) return null
575  const rows = responses.filter((r) => r && r.headers && typeof r.headers === 'object')
576    .sort((a, b) => String(b.time || '').localeCompare(String(a.time || '')))
577  for (const r of rows) {
578    const out = []
579    for (const [name, v] of Object.entries(r.headers)) {
580      const m = /^anthropic-ratelimit-unified-(.+)-utilization$/.exec(name)
581      if (!m || m[1].startsWith('grace')) continue
582      const util = Number(v)
583      if (isNaN(util)) continue
584      out.push({ key: m[1], util, reset: Number(r.headers['anthropic-ratelimit-unified-' + m[1] + '-reset']) || 0 })
585    }
586    if (out.length > 0) return out.sort((a, b) => windowSeconds(a.key) - windowSeconds(b.key) || a.key.localeCompare(b.key))
587  }
588  return null
589}
590
591// "5h" is 18000, "7d" and "7d-opus" 604800; 0 when the name says no length.
592function windowSeconds(key) {
593  const m = /^(\d+)([hd])/.exec(key)
594  return m ? Number(m[1]) * (m[2] === 'h' ? 3600 : 86400) : 0
595}
596
597// "5h", "weekly", "weekly opus".
598// How much of the weekly limit today has used, kept in the store so every
599// session counts the same day once: the first reading of the day is where
600// the day starts (yesterday's last, when there is one in the same week), and
601// what was used before a reset in the middle of the day is carried over.
602// One toast a day, from whichever session sees it first, when the day has
603// used DAY_SHARES days' worth: a week at that pace runs out days early.
604async function dayPace($, l, now) {
605  const t = new Date(now * 1000)
606  const day = t.getFullYear() + '-' + (t.getMonth() + 1) + '-' + t.getDate()
607  let s = null
608  try { s = await $.store.get(DAY_KEY) } catch (err) { s = null }
609  const ok = s && typeof s.day === 'string' && typeof s.base === 'number' && typeof s.last === 'number' && typeof s.reset === 'number'
610  const before = JSON.stringify(s)
611  const sameWeek = ok && Math.abs(l.reset - s.reset) < 3600
612  if (!ok || s.day !== day) s = { day, base: sameWeek ? s.last : l.util, reset: l.reset, carried: 0, last: l.util, warned: false }
613  else if (!sameWeek) s = { ...s, carried: s.carried + Math.max(0, s.last - s.base), base: 0, reset: l.reset }
614  s.last = l.util
615  const used = (s.carried || 0) + Math.max(0, l.util - s.base)
616  if (!s.warned && used >= DAY_SHARES / 7) {
617    s.warned = true
618    $.ui.toast('🟡 Plan pace: ' + Math.round(used * 100) + '% of the weekly limit used today, over ' + DAY_SHARES + " days' share (" + Math.round((DAY_SHARES * 100) / 7) + '%). ' + Math.round(l.util * 100) + '% used, resets ' + when(l.reset, now), { timeoutMs: 30000 })
619  }
620  if (JSON.stringify(s) !== before) {
621    try { await $.store.set(DAY_KEY, s) } catch (err) { $.ui.log("could not save today's share of the weekly limit: " + err) }
622  }
623}
624
625function limitName(key) {
626  return key.replace(/^7d/, 'weekly').replace(/[-_]+/g, ' ')
627}
628
629// A time ahead: "16:00 (1h41m)" today, "Thu 02:00" on another day.
630function when(epoch, now) {
631  const t = new Date(epoch * 1000)
632  const same = t.toDateString() === new Date(now * 1000).toDateString()
633  return same ? clock(epoch) + ' (' + hm((epoch - now) / 60) + ')' : DAYS[t.getDay()] + ' ' + clock(epoch)
634}
635const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
636
637// The plan on the panel's License row, and what it costs a month where that
638// is a published flat price. null for an API key, or no row.
639export function planOf(summary) {
640  const line = parseAnsi(summary || '').map((l) => l.text).find((t) => /License:/.test(t))
641  if (!line) return null
642  const label = line.replace(/^.*License:\s*/, '').trim()
643  if (!label || /^API key/.test(label)) return null
644  const price = /^Max \(20x\)/.test(label) ? 200 : /^Max \(5x\)/.test(label) ? 100 : /^Pro\b/.test(label) ? 20 : 0
645  return { label, price }
646}
647
648// ---- the sidebar -------------------------------------------------------------
649
650const TIER = { green: 'green', yellow: 'yellow', red: 'red', purple: 'magenta', cyan: 'cyan' }
651const ACCENT = 'cyan'
652const BLOCKS = ' ▁▂▃▄▅▆▇█'
653
654export function panel(Box, Text, d, width, now, feedError, layout, extras = [], burst = null, limits = null, extra = null, full = null) {
655  const T = (children, props = {}) => Text({ ...props, children: Array.isArray(children) ? children : [children] })
656  const rows = []
657  if (!d) {
658    rows.push(T('Usage', { bold: true, color: ACCENT }))
659    rows.push(T('Starting the usage panel for this session…', { dimColor: true }))
660    rows.push(T('The first figures take up to a minute: ccusage reads every transcript once.', { dimColor: true, wrap: 'wrap' }))
661    if (feedError) rows.push(T(feedError, { color: 'red', wrap: 'wrap' }))
662    return rows
663  }
664  const W = width
665  const age = Math.max(0, now - (d.at || now))
666  const stale = age > STALE_S
667
668  // Header
669  rows.push(Box({
670    flexDirection: 'row', justifyContent: 'space-between', children: [
671      T('◆ Usage', { bold: true, color: ACCENT }),
672      T(stale ? 'stopped ' + ago(age) : 'live · ' + clock(d.at), { color: stale ? 'red' : undefined, dimColor: !stale }),
673    ],
674  }))
675  if (feedError) rows.push(T(feedError, { color: 'red', wrap: 'wrap' }))
676
677  // Most specific first: this session, then today across sessions, then the
678  // last 30 days, then this Mac's set-up. Each block's insights sit under it.
679  // The order, and which are shown, is the person's (see layoutOf).
680  const tips = insights(d, now, limits, extra, burst)
681  const notes = (scope) => tips.filter((t) => t.scope === scope).map((t) =>
682    Box({ flexDirection: 'row', children: [T((t.icon || '•') + ' ', { color: TIER[t.tier] || ACCENT }), T(t.text, { wrap: 'wrap' })] }))
683  // Each section is a card: a rounded border, its heading inside, so where
684  // one ends and the next starts is plain. The border and padding take 4.
685  const IW = Math.max(26, W - 4)
686  const draw = {
687    session: () => [...sessionSection(Box, T, d, IW, burst, extra && extra.warn, full), ...notes('session'), ...notes('burst')],
688    mac: () => {
689      const foot = [...footer(Box, T, d), ...burstRows(Box, T, burst), ...extras]
690      return foot.length > 0 ? [heading(T, 'This Mac'), ...foot] : []
691    },
692    turns: () => turnsTable(T, d),
693    plan: () => {
694      const body = planSection(Box, T, d, IW, now, limits, extra)
695      return body.length > 0 ? [...body, ...notes('plan')] : []
696    },
697    savings: () => {
698      const out = []
699      for (const part of [savingsSection(Box, T, IW, extra), strategiesSection(Box, T, IW, extra), overflowSection(Box, T, IW, extra)]) {
700        if (part.length === 0) continue
701        if (out.length > 0) out.push(T(' '))
702        out.push(...part)
703      }
704      return out
705    },
706    today: () => [...todaySection(Box, T, d, IW, now), ...notes('today')],
707    sessions: () => topSection(Box, T, d, IW),
708    days: () => daysSection(Box, T, d, IW),
709    projects: () => [...projectsSection(Box, T, d, IW), ...notes('general')],
710  }
711  const lay = layoutOf(layout)
712  for (const id of lay.order) {
713    if (lay.hidden.includes(id)) continue
714    const body = draw[id]()
715    if (body.length > 0) rows.push(card(Box, id, body))
716  }
717  if (lay.hidden.length > 0) rows.push(T('Hidden: ' + lay.hidden.join(', ') + ' (/' + 'usage-panel show <name>)', { dimColor: true, wrap: 'wrap' }))
718  return rows
719}
720
721function card(Box, key, children) {
722  return Box({ key, flexDirection: 'column', borderStyle: 'round', borderColor: 'gray', borderDimColor: true, paddingLeft: 1, paddingRight: 1, children })
723}
724
725// A card's title, bold, with a short dim note after it.
726function heading(T, title, note) {
727  return T([T(title, { bold: true, color: ACCENT }), note ? T('  ' + note, { dimColor: true }) : ''], { wrap: 'truncate-end' })
728}
729
730// A smaller heading inside a card, for one chart of several: in bold, under
731// a blank row, so the charts do not run into each other.
732function sub(Box, T, title, note) {
733  return Box({ flexDirection: 'row', marginTop: 1, children: [T([T(title, { bold: true, color: ACCENT }), note ? T('  ' + note, { dimColor: true }) : ''], { wrap: 'truncate-end' })] })
734}
735
736// A colour for each bar in a chart of projects or sessions, by its place.
737// Cyan is this session's and grey the rest's, so neither is here.
738const SHARE_COLOURS = ['blue', 'green', 'yellow', 'magenta']
739function colourer() {
740  const seen = new Map()
741  return (name) => {
742    if (!seen.has(name)) seen.set(name, SHARE_COLOURS[seen.size % SHARE_COLOURS.length])
743    return seen.get(name)
744  }
745}
746
747// ---- this session
748
749function sessionSection(Box, T, d, W, burst, warnPct, full) {
750  const s = d.session || {}
751  // A long folder loses its start, not its end: the end is what tells two
752  // repos apart.
753  const id = d.sid ? '*' + d.sid.slice(-5) : ''
754  const room = W - 'Session:'.length - 2 - (id ? id.length + 3 : 0)
755  const out = [heading(T, 'Session:', [id, s.folder ? cut(s.folder, Math.max(4, room)) : ''].filter(Boolean).join(' · '))]
756  // Before the first reply the panel knows no model ("Unknown") and prices
757  // nothing ($0): say so rather than draw that as a reading.
758  const turnsSoFar = (d.turns && d.turns.turns) || []
759  const sent = burst && !burst.down && burst.mod && burst.mod.session && burst.mod.session.context > 0 ? burst.mod.session : null
760  if (turnsSoFar.length === 0 && !(s.ctx > 0) && !sent) {
761    out.push(T('No reply yet: the figures start with the first one.', { dimColor: true, wrap: 'wrap' }))
762    return out
763  }
764  const turns = (d.turns && d.turns.turns) || []
765  const n = turns.length > 0 ? turns[turns.length - 1][0] : 0
766  out.push(T([
767    T(s.model || 'model unknown', { color: TIER[s.model_tier], bold: true }),
768    T('  '),
769    T(s.cost == null ? '--' : money(s.cost, 2), { bold: true, color: TIER[s.tier] }),
770    T(s.rate == null ? '' : '  ' + money(s.rate, 2) + '/hr', { color: TIER[s.rate_tier] }),
771    T(n ? '  ' + n + (n === 1 ? ' turn' : ' turns') : '', { dimColor: true }),
772    s.avg_session > 0 && s.cost != null && s.cost >= s.avg_session * 0.5 ? T('  ' + ratio(s.cost / s.avg_session) + ' avg', { dimColor: true }) : '',
773  ], { wrap: 'truncate-end' }))
774
775  // One context bar. With Claude Burst it is Burst's: what is really sent
776  // (after Burst's own compaction, which Claude Code's figure does not know
777  // about), by part, against the model's window, with Burst's warning and
778  // compaction lines marked. Otherwise the
779  // panel's gauge against the model's window, its colour thresholds as ticks.
780  if (sent) {
781    out.push(...sentBar(Box, T, sent, W, s.win || 0, warnPct, full))
782    if (s.compacting) out.push(T('  ⟳ a summary is ready: the next prompt compacts', { color: 'cyan' }))
783    else if (sent.state === 'warning') out.push(T('! Close to the limit: ' + limitLabel(sent) + '.', { color: 'yellow', wrap: 'wrap' }))
784    // "compacted" is not said: it stays for the rest of the session and the
785    // bar, the growth chart and the turn table already show it.
786    else if (sent.state && sent.state !== 'ok' && sent.state !== 'compacted') out.push(T('⟳ ' + sent.state, { color: 'cyan', wrap: 'wrap' }))
787  } else if (s.ctx > 0 && s.win > 0) {
788    const pct = (s.ctx * 100) / s.win
789    const label = k(s.ctx) + '/' + k(s.win) + ' ' + Math.round(pct) + '%'
790    const barW = Math.max(6, W - 8 - label.length)
791    const c = pct >= CTX_RED * 100 ? 'red' : pct >= CTX_AMBER * 100 ? 'yellow' : 'green'
792    out.push(Box({
793      flexDirection: 'row', columnGap: 1, children: [
794        T('ctx   ', { dimColor: true }),
795        gauge(T, pct, barW, c, [CTX_AMBER * 100, CTX_RED * 100]),
796        T(label, { color: c }),
797      ],
798    }))
799    // What the two ticks are, in this model's tokens.
800    out.push(Box({
801      flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: [
802        T([T('│ ', { color: 'yellow' }), T('expensive from ' + k(s.win * CTX_AMBER), { dimColor: true })]),
803        T([T('│ ', { color: 'red' }), T('wasteful from ' + k(s.win * CTX_RED), { dimColor: true })]),
804      ],
805    }))
806    if (s.compacting) out.push(T('  ⟳ a summary is ready: the next prompt compacts', { color: 'cyan' }))
807  }
808
809  // Context per turn, with compactions marked; cost per turn underneath.
810  if (turns.length >= 3) {
811    const cols = Math.max(10, W - 7)
812    const shown = turns.slice(-cols)
813    const ctxs = shown.map((t) => t[1])
814    const drops = new Set()
815    for (let i = 1; i < shown.length; i++) if (shown[i][1] < shown[i - 1][1] * 0.6) drops.add(i)
816    const win = s.win || 0
817    const ctxColour = (v) => (!win ? 'blue' : v >= win * CTX_RED ? 'red' : v >= win * CTX_AMBER ? 'yellow' : 'blue')
818    out.push(Box({
819      flexDirection: 'row', columnGap: 1, marginTop: 1, children: [
820        T('growth', { dimColor: true }),
821        spark(T, ctxs, (v, i) => (drops.has(i) ? 'cyan' : ctxColour(v)), 0),
822      ],
823    }))
824    const costs = shown.map((t) => (t[4] == null ? 0 : t[4]))
825    const med = median(costs.filter((c) => c > 0))
826    // A row's bars reach the cell's foot and the next row's reach its head,
827    // so stacked directly they read as one shape: a blank row between each.
828    // The turn after a compaction writes the new context to the cache once,
829    // so it costs more by design: cyan like its drop above, never red.
830    out.push(Box({
831      flexDirection: 'row', columnGap: 1, marginTop: 1, children: [
832        T('$/turn', { dimColor: true }),
833        spark(T, costs, (v, i) => (drops.has(i) ? 'cyan' : med > 0 && v > med * 4 ? 'red' : med > 0 && v > med * 2 ? 'yellow' : 'green'), 0),
834      ],
835    }))
836  }
837  return out
838}
839
840// ---- Claude Burst: this session's context as its gateway sends it
841
842// One colour per part, in the gateway's order.
843const PART_COLOURS = {
844  'System prompt': 'ansi256(244)', // mid grey: 237 vanished into a dark terminal
845  'System tools': 'cyan',
846  'MCP tools': 'magenta',
847  'Memory files': 'yellow',
848  'Messages': 'blue',
849  'Tool results': 'green',
850}
851
852// What is left of the bar: light grey, lighter than the system prompt's
853// mid grey and a colour no part has. White glared beside the parts.
854const FREE_COLOUR = 'ansi256(250)'
855
856// Where Burst compacts this session: what GetAutoCompactionThreshold says
857// for this folder, and until it has answered, what the session's own
858// figures carry.
859function compactAt(s) {
860  const a = extra && extra.auto
861  if (a && a.at > 0) return a.at
862  return s && s.compact_at > 0 ? s.compact_at : 0
863}
864
865// Whether the limit is one Intelligent Compaction Mode learned for this
866// repository, not the fixed setting: by GetAutoCompactionThreshold, and
867// until it has answered, by the session's own figures. A learned limit
868// that is the fixed one is the fixed one: Intelligent Compaction Mode goes
869// back to it when compacting sooner does not pay, and "Auto Compact at
870// 300k (Intelligent)" then named a size nobody learned.
871function learnedLimit(s) {
872  const a = extra && extra.auto
873  if (a) return a.source === 'learned' && a.at > 0 && !(a.fixed > 0 && a.at >= a.fixed)
874  return !!(s && s.learned)
875}
876
877// "Auto Compact at 300k", the fixed limit and a learned one alike: a
878// learned one is told apart by its colour and the word beside it.
879function limitLabel(s) {
880  return 'Auto Compact at ' + k(compactAt(s))
881}
882
883// What stands beside the limit when it is learned: Burst's Intelligent
884// Compaction Mode chose it. '' for the fixed one.
885function limitNote(s) {
886  return learnedLimit(s) ? '(Intelligent)' : ''
887}
888
889// The context Burst sends for this session as a stacked bar against the
890// model's window, with a line where Burst compacts (the limit is a setting,
891// not the room there is), a legend under it, and
892// what Claude Code itself still holds when that is more. Without a window
893// the bar is against the compaction limit alone.
894function sentBar(Box, T, s, W, win, warnPct, full) {
895  const limit = compactAt(s)
896  const warn = limit ? Math.round((limit * (warnPct > 0 && warnPct < 100 ? warnPct : 80)) / 100) : 0
897  const whole = win > limit && win >= s.context
898  const scale = whole ? win : Math.max(limit, s.context)
899  const pct = Math.round((s.context * 100) / scale)
900  const label = k(s.context) + (whole || limit ? '/' + k(scale) + ' ' + pct + '%' : '')
901  const colour = limit && s.context >= limit ? 'red' : warn && s.context >= warn ? 'yellow' : undefined
902  // Largest first, bar and legend alike: what to cut is read off the left.
903  const parts = (s.parts || []).filter((p) => p.tokens > 0).sort((a, b) => b.tokens - a.tokens)
904  const barW = Math.max(6, W - 8 - label.length)
905  const free = scale - s.context
906  let bar
907  {
908    // What is used takes its true share of the bar, so it meets the two
909    // lines where it should; the parts share that, the smallest giving way
910    // when there are more parts than cells (the legend still names them).
911    const used = free > 0 ? Math.max(1, Math.min(barW - 1, Math.round((s.context * barW) / scale))) : barW
912    // Before Burst has the breakdown by part the used share is one block,
913    // on the same bar: the room left and the limit's line look the same.
914    const onBar = parts.length > 0 ? parts.slice(0, used) : [{ tokens: s.context }]
915    const widths = share(onBar.map((p) => p.tokens), used)
916    const cells = []
917    onBar.forEach((p, i) => { for (let n = 0; n < widths[i]; n++) cells.push(['█', p.name ? PART_COLOURS[p.name] : colour || 'blue']) })
918    while (cells.length < barW) cells.push(['█', FREE_COLOUR])
919    // The two lines, over whatever is in that cell: still there once passed.
920    if (whole) {
921      const at = (tokens) => Math.max(1, Math.min(barW - 1, Math.round((tokens * barW) / scale)))
922      const stop = at(limit)
923      // A thin red line with an arrow pointing at it, each drawn on the
924      // colour of the cell it stands in: on the terminal's own background
925      // the line had a dark gap on both sides and cut the bar in two.
926      // The line is on the right edge of the cell before the limit, so it
927      // stands at the limit itself and touches the arrow's cell.
928      if (stop < cells.length) {
929        if (stop > 0) cells[stop - 1] = ['▕', 'red', cells[stop - 1][1]]
930        cells[stop] = ['◀', 'red', cells[stop][1]]
931      }
932    }
933    const segs = []
934    for (const [ch, c, bg] of cells) {
935      const last = segs[segs.length - 1]
936      if (last && last.ch === ch && last.c === c && last.bg === bg) last.text += ch
937      else segs.push({ ch, c, bg, text: ch })
938    }
939    bar = T(segs.map((g) => T(g.text, g.bg ? { color: g.c, backgroundColor: g.bg } : { color: g.c })))
940  }
941  const out = [Box({ flexDirection: 'row', columnGap: 1, children: [T('ctx   ', { dimColor: true }), bar, T(label, { color: colour })] })]
942  if (parts.length > 0) {
943    const key = parts.map((p) => [PART_COLOURS[p.name], p.name + ' ' + k(p.tokens)])
944    // The rest of the bar is room left, in the window or before Burst
945    // compacts: named, last.
946    if (free > 0) key.push([FREE_COLOUR, 'Free ' + k(free)])
947    out.push(Box({
948      flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: key.map(([c, text]) =>
949        T([T('■ ', { color: c }), T(text, { dimColor: true })])),
950    }))
951  }
952  if (whole) {
953    out.push(Box({
954      flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, children: [
955        T([T('▕◀ ', { color: 'red' }), T(limitLabel(s), learnedLimit(s) ? { color: 'cyan' } : { dimColor: true })]),
956        ...(limitNote(s) ? [T(limitNote(s), { dimColor: true })] : []),
957      ],
958    }))
959  }
960  // Claude Code's own history, which Burst's compaction never shrinks.
961  // Always said: before the two part it is what Burst sends, and a line that
962  // came and went read as a figure gone missing. Its colour is how far the
963  // two have parted, and what a restart will do about it.
964  const held = Math.max(s.raw || 0, s.context || 0)
965  if (held > 0) {
966    const size = T('Uncompacted Size: ' + k(held), { color: heldColour(s, held) })
967    // The button only where there is enough held for it to be worth a press.
968    if (full && win > 0 && held >= win * FULL_FROM) out.push(Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: [size, full] }))
969    else out.push(size)
970  }
971  return out
972}
973
974// `width` cells shared out in proportion, every amount above zero getting at
975// least one: a part too small for a cell, or the last of the room left, is
976// still on the bar. What that costs comes off the widest.
977export function share(amounts, width) {
978  const total = amounts.reduce((a, b) => a + b, 0)
979  if (!(total > 0) || width <= 0) return amounts.map(() => 0)
980  const ideal = amounts.map((a) => (a * width) / total)
981  const out = ideal.map((x, i) => (amounts[i] > 0 ? Math.max(1, Math.round(x)) : 0))
982  let sum = out.reduce((a, b) => a + b, 0)
983  while (sum > width) {
984    const i = out.indexOf(Math.max(...out))
985    if (out[i] <= 1) break
986    out[i]--
987    sum--
988  }
989  while (sum < width) {
990    let best = 0
991    out.forEach((_, i) => { if (ideal[i] - out[i] > ideal[best] - out[best]) best = i })
992    out[best]++
993    sum++
994  }
995  return out
996}
997
998// The share of the model's window Claude Code must hold before the Full
999// Async Compaction button is drawn beside Uncompacted Size.
1000const FULL_FROM = 0.5
1001
1002// Where Burst's mod (burst-session, HANDOFF_AT) compacts a session opened
1003// again with the summary Burst already wrote.
1004const HANDOFF_AT = 300000
1005
1006// The colour of Uncompacted Size. Green: Claude Code holds what Burst sends,
1007// nothing has been compacted. Yellow: Burst sends a summary and Claude Code
1008// holds more than a tenth above it. Red: it holds enough that opening the
1009// session again (--resume, --continue) compacts Claude Code's own copy with
1010// that summary, unless the hand-off is turned off on Burst's dashboard.
1011function heldColour(s, held) {
1012  if (!(held > (s.context || 0) * 1.1)) return 'green'
1013  const handoff = !(burst && burst.mod && burst.mod.handoff === false)
1014  return handoff && held >= HANDOFF_AT ? 'red' : 'yellow'
1015}
1016
1017// Burst's rows for This Mac, under the panel's Proxy State: a dashboard that
1018// is not answering, and each problem still standing.
1019function burstRows(Box, T, burst) {
1020  if (!burst) return []
1021  if (burst.down || !burst.mod) return [T('⚡ Burst dashboard not answering', { color: 'red', bold: true, wrap: 'wrap' })]
1022  return (burst.mod.problems || []).map((p) => {
1023    const c = p.severity === 'error' ? 'red' : 'yellow'
1024    return Box({ flexDirection: 'row', children: [T('⚠ ', { color: c }), T(p.title, { color: c, bold: true, wrap: 'wrap' })] })
1025  })
1026}
1027
1028// ---- plan utilisation: the plan's limits, and what the month's use is worth
1029
1030function planSection(Box, T, d, W, now, limits, extra) {
1031  const plan = planOf(d.summary)
1032  const rows = (limits || []).filter((l) => l.reset > now)
1033  const hasBurst = !!(d.burst && d.burst.dashboard)
1034  if (rows.length === 0 && !plan) return []
1035  const out = [heading(T, 'Plan Utilisation', plan ? plan.label : '')]
1036  const nameW = Math.max(0, ...rows.map((l) => limitName(l.key).length))
1037  const labelOf = (l) => lpad(Math.round(l.util * 100) + '%', 4) + '  resets ' + when(l.reset, now)
1038  // One bar width for every row, so the bars can be compared.
1039  const labelW = Math.max(0, ...rows.map((l) => labelOf(l).length))
1040  // Beside its figures where there is room for a bar worth reading; in a
1041  // narrow sidebar the bar has a row of its own, the card's whole width, and
1042  // the figures no longer wrap under it.
1043  const beside = W - nameW - 2 - labelW
1044  const stacked = beside < LIMIT_BAR_MIN
1045  for (const [i, l] of rows.entries()) {
1046    const pct = Math.round(l.util * 100)
1047    const c = pct >= LIMIT_ALARM * 100 ? 'red' : pct >= LIMIT_WARN * 100 ? 'yellow' : 'green'
1048    const name = T(pad(limitName(l.key), nameW), { dimColor: !stacked, bold: stacked })
1049    const label = T(labelOf(l), { color: c === 'green' ? undefined : c, wrap: 'truncate-end' })
1050    // Any use at all is a cell on the bar: 4% of a short one rounded to none.
1051    const bar = (w) => gauge(T, l.util > 0 ? Math.max(pct, 100 / w) : 0, w, c, [])
1052    if (stacked) {
1053      out.push(Box({ flexDirection: 'row', columnGap: 1, marginTop: i > 0 ? 1 : 0, children: [name, label] }))
1054      out.push(bar(W))
1055    } else {
1056      out.push(Box({ flexDirection: 'row', columnGap: 1, children: [name, bar(Math.max(6, beside)), label] }))
1057    }
1058  }
1059  out.push(...limitShares(Box, T, d, W, rows, extra))
1060  if (rows.length === 0) {
1061    out.push(T(hasBurst ? 'No limit reading yet: it comes with the next reply.' : 'How much of the limits is used comes from Claude Burst, which reads it off Anthropic\'s replies.', { dimColor: true, wrap: 'wrap' }))
1062  }
1063  // What the flat price buys: the month's use at pay-as-you-go rates.
1064  if (plan && plan.price > 0 && d.month > 0) {
1065    out.push(Box({
1066      flexDirection: 'row', marginTop: out.length > 1 ? 1 : 0, children: [T([
1067        T(money(d.month), { bold: true }),
1068        // The shorter wording where the longer would wrap.
1069        T((money(d.month) + ' at API rates this month (' + money(plan.price) + ' plan)').length <= W ? ' at API rates this month (' + money(plan.price) + ' plan)' : ' this month at API rates vs ' + money(plan.price), { dimColor: true }),
1070      ], { wrap: 'wrap' })],
1071    }))
1072  }
1073  return out
1074}
1075
1076// The narrowest bar a limit is drawn with beside its figures: under this it
1077// has a row of its own.
1078const LIMIT_BAR_MIN = 16
1079
1080// What used each limit, by project: the limit's reading shared out by each
1081// project's cost at API rates since the window opened. An estimate, and
1082// said to be: Anthropic does not publish how it weighs tokens against a
1083// limit. The three largest and the rest, this session's project in cyan.
1084function limitShares(Box, T, d, W, rows, extra) {
1085  const out = []
1086  const here = (d.session && d.session.folder) || ''
1087  // The same project the same colour in both charts.
1088  const colourOf = colourer()
1089  for (const l of rows) {
1090    const by = extra && extra.by && extra.by[l.key]
1091    if (!by || !(by.total > 0)) continue
1092    const top = by.rows.slice(0, 3)
1093    const rest = by.total - top.reduce((a, x) => a + x.usd, 0)
1094    const list = rest / by.total >= 0.005 ? [...top, { name: 'other', usd: rest, other: true }] : top
1095    const nameW = Math.min(20, Math.max(...list.map((x) => x.name.length)))
1096    const barW = Math.max(4, W - nameW - 6)
1097    // The note gives way before the heading does.
1098    const title = 'What used the ' + limitName(l.key) + ' limit'
1099    const note = title.length + 14 <= W ? 'est. by cost' : title.length + 6 <= W ? 'est.' : ''
1100    out.push(sub(Box, T, title, note))
1101    for (const x of list) {
1102      const pts = (x.usd / by.total) * l.util * 100
1103      const n = Math.max(1, Math.round((x.usd / top[0].usd) * barW))
1104      const mine = !x.other && x.name === here
1105      out.push(Box({
1106        flexDirection: 'row', columnGap: 1, children: [
1107          T(pad(cut(x.name, nameW), nameW), { bold: mine, color: mine ? ACCENT : undefined, dimColor: !!x.other }),
1108          T('█'.repeat(Math.min(n, barW)) + ' '.repeat(Math.max(0, barW - n)), { color: mine ? ACCENT : x.other ? 'gray' : colourOf(x.name) }),
1109          T(lpad(pts < 1 ? '<1%' : Math.round(pts) + '%', 4)),
1110        ],
1111      }))
1112    }
1113  }
1114  return out
1115}
1116
1117// ---- pauseless compaction: what Claude Burst's compaction has saved
1118
1119// Each turn after a compaction sends the summary in place of the history;
1120// the saving is what those turns would have cost with the history still in,
1121// less what the summaries cost to write and the one cache rewrite each
1122// compaction causes. Burst works it out; this draws it.
1123function savingsSection(Box, T, W, extra) {
1124  const c = extra && extra.comp
1125  if (!c || !(c.n > 0)) return []
1126  const out = [heading(T, 'Pauseless Compaction')]
1127  out.push(T([
1128    T(money(Math.abs(c.net)), { bold: true, color: c.net >= 0 ? GAIN : 'red' }),
1129    T(c.net >= 0 ? ' saved' : ' lost', { color: c.net >= 0 ? GAIN : 'red' }),
1130    T('  ' + c.n + (c.n === 1 ? ' compaction' : ' compactions'), { dimColor: true }),
1131  ], { wrap: 'truncate-end' }))
1132  const w = lastWeek(c.daily, c.days)
1133  out.push(...figures(Box, T, c.days, !!w, [
1134    ['Compactions', String(c.n), w && String(w.n)],
1135    ['Not re-sent', money(c.saved, 2), w && money(w.saved, 2), true],
1136    ['Summaries', money(-c.summary, 2), w && money(-w.summary, 2)],
1137    ['Cache rewrites', money(-c.rewrite, 2), w && money(-w.rewrite, 2)],
1138    ['Net savings', money(c.net, 2), w && money(w.net, 2), true],
1139    ['Each', c.n > 0 ? money(c.net / c.n, 2) : '', w && w.n > 0 ? money(w.net / w.n, 2) : '', true],
1140    ['Bill cut by', ofSpend(c.net, extra.spent), w ? ofSpend(w.net, extra.spent7) : '', true],
1141  ]))
1142  out.push(T(' '))
1143  if (c.tokens > 0) {
1144    // On one row where it fits; in a narrow sidebar the largest has its own.
1145    const sent = big(c.tokens) + ' tokens not re-sent'
1146    const largest = c.before > c.after && c.after > 0 ? 'largest ' + k(c.before) + ' → ' + k(c.after) : ''
1147    const one = largest ? sent + ', ' + largest : sent
1148    for (const line of one.length <= W || !largest ? [one] : [sent, 'Largest ' + largest.slice(8)]) out.push(T(line, { dimColor: true, wrap: 'truncate-end' }))
1149  }
1150  const mine = extra.saved
1151  if (mine && mine.n > 0) {
1152    const one = mine.n === 1
1153    out.push(T([
1154      T('This session: ', { dimColor: true }),
1155      T(money(Math.abs(mine.net), 2), { color: mine.net >= 0 ? GAIN : 'yellow' }),
1156      T((mine.net >= 0 ? ' saved' : ' lost so far') + ', ' + mine.n + (one ? ' compaction' : ' compactions'), { dimColor: true }),
1157    ], { wrap: 'wrap' }))
1158  }
1159  return out
1160}
1161
1162// The days a card's second column covers, where Burst's window is longer.
1163const WEEK = 7
1164
1165// The last WEEK days of a card's daily figures summed, field by field. null
1166// where the window is no longer than that: one column says it all.
1167function lastWeek(daily, days) {
1168  if (!(days > WEEK) || daily.length === 0) return null
1169  const out = {}
1170  for (const x of daily.slice(-WEEK)) for (const key of Object.keys(x)) if (typeof x[key] === 'number') out[key] = (out[key] || 0) + x[key]
1171  return out
1172}
1173
1174// A saving as the share it cut from the bill: against what the same days
1175// cost, every provider, at API rates, plus the saving itself. '' where
1176// Burst has no spend for them.
1177function ofSpend(net, spent) {
1178  if (!(spent > 0)) return ''
1179  // Against the bill as it would have been, without the saving.
1180  if (!(spent + net > 0)) return ''
1181  const p = (net / (spent + net)) * 100
1182  return (Math.abs(p) < 10 ? p.toFixed(1) : Math.round(p)) + '%'
1183}
1184
1185// The colour of money gained in the savings tables; money lost is red.
1186const GAIN = 'blue'
1187
1188// Money and shares by their sign: red below nothing, blue otherwise. A
1189// plain count, or nothing, has no colour.
1190function tone(v) {
1191  return typeof v !== 'string' || !/[$%]/.test(v) ? undefined : v.startsWith('-') ? 'red' : GAIN
1192}
1193
1194// A card's figures as a table: a column for Burst's window and, where that
1195// is longer than a week, one for the last 7 days. Each row is [label,
1196// window, week, saving]: a saving's figures are coloured by tone(), the
1197// rest (counts, prices, what the saving cost) have no colour.
1198function figures(Box, T, days, two, rows) {
1199  const cw = 10
1200  const lw = 15