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…

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 Sizewith 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
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-panel | Show the sidebar in this session, beside the transcript. |
/hide-cost-panel | Hide it in this session. New sessions still open it; /usage-panel unpin stops that. |
/usage-panel | Open the sidebar (or focus it). Esc puts you back in the prompt. |
/usage-panel unpin | Stop it opening by itself in new sessions. |
/usage-panel pin | Open 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 / reset | Show 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):
/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.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.
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).
<img src="docs/card-session.png" alt="The Session card" width="460">
| On screen | What it is |
|---|---|
*e7d21 · my-app | The last five characters of the session id, and the folder it runs in. |
Opus 5.5 | The model of the latest turn, read from the transcript. |
$14.62 | What this session has cost so far, coloured against your 7-day average session. |
$3.10/hr | This session's burn rate. |
149 turns | Replies from the model so far. |
2.4× avg | This 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 key | With 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: 320k | Claude 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. |
growth | Context 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. |
$/turn | Cost, 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. |
<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.
<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 window | On a 1M window | Colour | What the card says |
|---|---|---|---|
| Under 40% | under 400k | Green | Nothing, or Grows 3k/turn: amber (400k) in ~40 turns. |
| 40% to 70% | 400k to 700k | Amber | Getting expensive: every turn re-sends 522k. /compact, or /clear at a break in the work. |
| Over 70% | over 700k | Red | Wasteful: every turn re-sends 760k. /compact now, or /clear and start fresh. |
expensive from 400k, wasteful from 700k.<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.
| Column | What it is |
|---|---|
Turn | The turn's number in this session. |
Model | The 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 ?. |
Cost | That turn at the model's published rates, cache reads and writes included. ? is a model with no known price. |
Input | The 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). |
Cache | The 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.
<img src="docs/card-mac.png" alt="The This Mac card" width="460">
| On screen | What it is |
|---|---|
Proxy State | With 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. |
License | The plan Claude Code is signed in with, or API key when it is metered. |
⚠ rows | With 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 ! rows | The 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.
<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 screen | What 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:00 | The 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 limit | The 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>
<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 screen | What it is |
|---|---|
30 days, 7 days | The column headings: the window Burst keeps these figures over, and the last 7 days of it. |
$105 saved | The net saving over the window: blue when compaction has paid for itself, red (lost) when it has not yet. |
| Blue and red figures | Blue 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. |
Compactions | How many times Burst compacted. |
Not re-sent | What the turns after each compaction would have cost with the full history still in the context. |
Summaries | What the background calls that wrote the summaries cost. |
Cache rewrites | Each compaction changes the context, so the next turn writes it to the cache once at the higher rate. |
Net savings | Not re-sent, less the other two. Red with a minus when it is a loss. |
| Each | Net savings per compacti
hooks/register.js 1905 lines1// 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