See what your next message costs: a live band above the prompt with the prompt-cache countdown, the re-warm price, session cost, context and your 5h/7d limits.

The price of your next Claude Code message, before you send it. This Claude Code plugin draws a calm band above the prompt that answers: is my cache still warm, what will the next message cost if it isn't, what is this session costing, and am I close to a limit? It spends no tokens and makes no network calls. By default it is one row of chips:

Two more layouts draw the same readings in other shapes: pulse (the session's trace, for trends) and departures (a split-flap board, for times). Try every state live on the website, and see the changelog for what changed in each version. Found something off? Open an issue.
claude plugin install nextcost --marketplace HMarzban/nextcost
It loads in your next session, or run /reload-plugins in an open one.
The band needs Claude Code with mods (function-hooks plugins). CI tests every change on 2.1.299 and the latest release (2.1.308 at 0.19.0). The one-line form needs Claude Code 2.1.292 or later; on an older one, add the marketplace first (claude plugin marketplace add HMarzban/nextcost), then claude plugin install nextcost@hossein-mods.
It draws in the terminal and the desktop app's Code tab, which are the surfaces with a band above the prompt. WSL sessions don't load plugins. The repository README covers installing inside a session or for a whole team, updating and uninstalling.
| Command | Effect |
|---|---|
/nextcost | Toggle visibility |
/nextcost more / less | Open or close the expanded view, and show the band if hidden |
/nextcost show / hide | Set visibility explicitly |
/nextcost layout <name> | Draw the band in another layout, for every session, and show it if hidden |
/nextcost layout | Name the layout in use and list them all |
| Variable | Effect |
|---|---|
NEXTCOST_APPEARANCE | dark (default), light or plain; see Appearance |
NO_COLOR | Forces plain |
NEXTCOST_GLYPHS | ascii or unicode, in the terminal only; see Glyphs in the terminal |
CLAUDE_CODE_PROMPT_CACHE_TTL | 5m or 1h, so the band doesn't guess the cache's lifetime; see Cache lifetime |
FORCE_PROMPT_CACHING_5M=1 | The same, for 5 minutes |
ENABLE_PROMPT_CACHING_1H=1 | The same, for an hour |
The promptCacheTtl setting in settings.json pins the lifetime too; see Cache lifetime.
Set them in the shell you start Claude Code from (export NEXTCOST_APPEARANCE=light, then claude). The band reads them when a session starts. Their names before 0.18.0, CC_BAND_APPEARANCE and CC_BAND_GLYPHS, still work.
This section describes chips, the default layout; the others are under Layouts.
| Chip | Shows | Turns amber when |
|---|---|---|
| Cache | A battery that drains as the cache ages, and the time until it goes cold; cache warm while Claude is working, cache warming before a new conversation's first reply, cache – when the band loaded mid-conversation and hasn't measured it yet | Its last minute: 0:47 left · re-warm ~$0.52 |
| Cost | The session's total so far | Never |
| Context | How full the conversation is toward auto-compaction (63% full), or of the model window when compaction is off | Near compaction: 95% full · compacts in ~8k. Without auto-compaction: 80% (!), 95% (!!) |
| 5h | Your 5-hour limit: usage on a bar, and the reset | 80%, or when your pace would fill it before it resets: full in ~40m |
| 7d | Your weekly limit, the same way | 80% |
| Tokens | Every token this conversation used | Never |
A bar carries one number, the one beside it. Pace is in words, on the chip when it matters and in the Limits card always. Once a window's reset time has passed, its chip says reset until the next reply brings a fresh reading.
Amber means act soon. The 5h and 7d chips are tinted green and purple so you can tell them apart; that's a label, not a warning. Nothing is ever red: a cold cache or a full meter is a price, not an error. Colour is never the only signal, since escalation always adds words or ! / !!.
On the desktop (not in plain), hover any chip for a one-line explanation that covers it, so it leads with the chip's figure (Resets in 3h 00m; 5-hour limit, all your Claude use). ▵ opens the expanded view. It starts with a line that says where you are:
~/workspace/nextcost · on main 3 changed · ↑2 ↓1
| Piece | Shows |
|---|---|
| Path | The project, home as ~, its folder in bold |
| Branch | The git branch, or detached at <commit> |
| Worktree | worktree of <repo> in a linked worktree |
| Changes | Files with uncommitted changes, or clean |
| Ahead / behind | Commits to push (↑) and to pull (↓), only when there are any |
On the desktop each piece has an icon and a hover explanation. As the line narrows, the path and branch shorten and the extras drop, least important first. When the band is too short to give it a row of its own, it moves to the footer, beside the buttons, in place of the hint. Outside a repository, or if git is missing or slow, it shows the path alone. git is read when the session starts, after each of your messages and when you open the cards, never while the band draws.
Then come four cards with every fact labelled:

| Card | Shows |
|---|---|
| Cache | Time left on a bar, what a re-warm would cost if it went cold, what the cache has saved, the hit rate, how long it lasts idle, unexpected rebuilds |
| Spend | The session total, a bar of the token split with its legend (input, output, cache reads), your last message |
| Context | How full toward auto-compaction, tokens in context, where compaction runs, room left, the model window |
| Limits | The window closest to its limit, led by its reset and pace where the row doesn't say them, resets Sat 13:30 · full before reset (a countdown in departures); then each window (5h, 7d, a gateway's spend limit) with its bar and value, and each of the others named on a line of its own with its reset and pace in words: 5h resets 3h 00m · on pace for ~10% |
The cards sit four across when they fit, else two by two, each line sharing its width equally; on the desktop each has a visible border. A card's title and headline share its first line, and when the band is short of rows each card drops its bar first (the chips already show it), then its least important facts, so the view never scrolls the buttons away. Expanding never changes the chip row; only the toggle's icon turns from ▵ to ▿, pointing the way the band will move. Below the cards, Collapse (key c) closes them and Hide band (key h) hides the band; /nextcost brings it back.
At full width in the terminal, chips' row reads:
◷ cache 52m $3.19 ◔ ██░░░░ 38% full 5h ░░░░░░ 4% │ ↻ 3h 00m 7d ██░░░░ 30% │ ↻ 2d 19h Σ 225k ▵
On the desktop app's Code tab the glyphs are small icons, but for a reset's ↻, and the bars are drawn as SVG meters.
Chips' row stays on one line. As it narrows, pieces give way in this order: the tokens chip, the 7d reset time, the 5h reset time, the context meter, a calm 7d chip, the limit bars, long wording, a calm context chip, then a calm 5h chip. An amber chip keeps its words longest; its reset time is the very last thing to go. Below about 55 columns, with several chips amber at once, the end of the row is clipped rather than wrapped.
Chips is one of three layouts. Each asks one question of the same readings. Chips, the default, opens with ▵ into four cards, Cache, Spend, Context and Limits; pulse and departures open into views of their own. All three draw one row collapsed, end it with the same ▵, and open with the same Collapse and Hide band buttons. Every layout turns amber at the same moments, by the same rules. Departures' board also says LAST CALL, calm, ten minutes before an hour's cache goes cold; only its last minute is amber. Pulse and departures draw their readings in one order: cache, spend, context, 5h, 7d. On the desktop, chips explains each pill on hover with its own cards. Open, chips puts the workspace line, with its hover cards, above its cards when the band has the rows, else in the footer beside Collapse and Hide band; pulse and departures keep it in the footer.
<img alt="The three layouts on the desktop, each drawn twice from the band's own output, calm and in the cache's last minute: chips, pulse and departures" src="https://raw.githubusercontent.com/HMarzban/nextcost/main/docs/band-layouts-light.png">
| Layout | Asks, and shows | Rows (desktop / terminal) |
|---|---|---|
chips | Is anything wrong right now? The row of chips above: the cache, the spend, the context and the limits at a glance; the default | 1 / 1 |
pulse | Is my cost per message rising, and will a limit fill before it resets? The session's trace, each reading on its own tile: its name, its figure, then its chart, a heartbeat for the cache, each message's cost as a beat, the context and the 5h window over time, and the week by day. The heartbeat is still; only the words and its ink change. Open, four cards like chips', Cache, Spend, Context and Limits, each with its chart: the cache's hour as a track, the beats over their average, the context under its compaction line, the 5h window and the 7d by day | 1 / 1 |
departures | When does the cache go cold, and when does each limit reset or fill? A split-flap board. Collapsed, each reading is its own module on the board's dark ground, its code in its own colour, then its status and time, such as CACHE DEPARTS 17:24 or 7D 85% RESET SAT 13:30 ! NEAR LIMIT, amber where it needs you; plain draws no ground, and at its narrowest brackets each reading once, [7D ! 85%]. Open, one board headed DEPARTURES and the time now, NOW 20:36, with quiet ITEM, STATUS, TIME and REMARKS headings: ITEM, STATUS and TIME are flaps, tiles only where there are characters, and the remarks plain text. Short of rows, the remarks give way first on the desktop, then the lines; an amber line never does. Below 60 columns it draws chips | 1 / 1 |
/nextcost layout pulse # draw pulse; /nextcost layout chips goes back
/nextcost layout # name the layout in use and list them all
The choice is kept in the plugin's store, so every session draws it, and one already open switches after its next reply. Choosing a layout shows a hidden band. A name it doesn't know changes nothing, and the reply lists the names. If the store can't keep the choice, the reply says so, and the layout lasts until Claude's next reply. Below 40 columns every layout draws chips, and departures does below 60. A layout from an earlier version that was removed (gauges, ledger, rings, tiles, week and forecast, gone in 0.14.0) draws chips, and /nextcost layout says so. Week's day cells now live in pulse, and forecast's clock times in departures.
On the desktop with NEXTCOST_APPEARANCE=plain there are no SVG charts, so a layout draws as text and takes its terminal rows.
Pulse draws its charts in braille in the terminal, always beside their numbers; its day cells are the desktop's alone. Open and short of rows, a card's chart steps down, two rows to one to none, before a card gives way, and an amber card never does. Its day cells for the week come from the 5h and 7d readings the band keeps in its store from every session, at most one per 15 minutes and no more than 672, a week's worth, so they cover the days before this session. As the row narrows, pulse and departures drop calm pieces first: spend, context and 5h give way, and the cache and the 7d never do.
NEXTCOST_GLYPHS=ascii # ASCII alone
NEXTCOST_GLYPHS=unicode # the band's own glyphs, even in a CJK locale
A terminal in a CJK locale draws some of the band's glyphs (█ │ · Σ …) two columns wide where the band counts one, so the row runs past the window's edge, and so does a terminal set to draw ambiguous-width characters wide. In a CJK locale (the first of LC_ALL, LC_CTYPE and LANG that is set starts with ja, zh or ko) the band draws ASCII by itself. The terminal setting can't be detected, so set NEXTCOST_GLYPHS=ascii if you use it, and with a screen reader, which reads braille as dots. In ASCII each glyph becomes one character or is dropped (█ is #, ▵ is ^, ◷ goes), a · between two facts reads , , text too long for its room is clipped rather than ended with …, and braille charts give way to their numbers. A wide character, such as a Japanese project's name, stays, and the band counts it two columns. It applies to every layout, chips included, and only in the terminal.
Pulse, departures and chips' Limits card name clock times in your local zone. Each time is read with the zone's offset in force at it, so a reset past a daylight-saving change says the clock that will show then, and the band follows a change of clocks within a minute, with no reply needed. Where the clock gives no offset, pulse names its days and hours in UTC, and a reset, on the board and the Limits card too, says how long until it comes rather than when (in 52m, not 14:32).
Claude Code caches the conversation server-side. While the cache is warm, re-reading the conversation costs a tenth of the normal input price or less, depending on the model (a twentieth on Opus 5.5 and Sonnet 5.5, a fortieth on Fable 5.1 and Mythos 5.1). It stays warm for a lifetime (5 minutes or an hour) counted from the last request. Go idle past that, and the next message rebuilds the whole conversation at the cache-write price, 1.25× input as Claude Code's ledger counts it. The context part of that message then costs ~25× the warm read on Opus 5.5 and Sonnet 5.5, ~50× on Fable 5.1 and ~12.5× on Haiku 5.5; output and new input cost the same either way. In a terminal session on the hour's cache, Claude Code may compact the conversation first; see Idle compaction.
The band never suggests sending a message to keep the cache warm. That would mean burning tokens to avoid burning tokens.
A compaction or a model switch rebuilds the cache on purpose, so neither counts as an unexpected rebuild.
A session the band hasn't seen a reply in yet, because you reopened it or the band reloaded, still says what the cache is doing and what it has cost.
When you resume a session (claude --resume, /resume, or a past session opened in the desktop app) or fork one, Claude Code says how long it has been idle, whether its cache has likely expired, and what re-caching it would cost. The band takes its word: the countdown runs from that idle time, the cache reads cold when Claude Code says it has likely expired, and the price shown is Claude Code's own when it gives one. The band also reads the session's transcript once, never while it draws, for two more facts:
1h idle or 5m idle with no · assumed. A lifetime set in the environment still wins.Where Claude Code doesn't say, or the band reloaded, it recalls when the session's last reply was and what a token costs on its model:
Past the cache's lifetime it shows cache cold · next message ~$2.34, the cost of writing the whole context to the cache again. Within it, the countdown runs from the last reply. With no price known for the model yet, it names the tokens instead. With nothing to recall, it stays at cache –.
No pricing table is reachable from a mod, so the rate is solved from the session's own bill. Each kind of token costs a fixed multiple of base input (a cache write 1.25×, output 5×, a cache read 0.1×, or 0.05× on Opus 5.5 and Sonnet 5.5 and 0.025× on Fable 5.1 and Mythos 5.1, per Anthropic's pricing), which leaves one unknown:
cost = r × (uncached + 1.25×written + read share×read + 5×output)
where read share is the cache-read multiple above.
Solve for r, then price the re-warm as a cache write of the whole conversation. After a compaction, it prices the summary instead. It's always shown with ~.
/clear, a resume or a reload, the rate is solved from what the current conversation has spent, not the session's whole ledger.r is the lower tier's rate, and it prices a re-warm of a conversation over 100k, and what the cache saved, at the tier each request paid. A reopened session's cost record doesn't say which requests crossed, so there the last reply's prompt stands for them all.The band records your 5-hour usage as it moves. Once it has at least 10 minutes and a 2-point rise to go on, it projects when you'd hit 100%, using the last 30 minutes. If that's before the limit resets, the chip says so. The limit is shared across all your Claude use, so the pace includes your other sessions and devices, and /clear keeps it. The projection hides once its newest reading is more than 15 minutes old.
A toast speaks where a chip turns amber:
Each fires once per crossing. The 5h chip also turns amber when your pace would fill it before it resets; that has no toast, since the projection moves with every reading.
NEXTCOST_APPEARANCE=dark # default: filled pills tuned for dark themes
NEXTCOST_APPEARANCE=light # filled pills tuned for light themes
NEXTCOST_APPEARANCE=plain # no backgrounds; every colour a theme key
NO_COLOR forces plain. plain has no hover cards and no SVG icons, since the expanded cards carry the same facts.
The default lifetime depends on billing: an hour on a subscription within plan usage, five minutes on usage credits or an API key. A mod can't read which applies, so the band assumes an hour and says · assumed. If it then sees the cache rebuild after a gap longer than five minutes, with the same model, it corrects itself to 5m. On a resumed session, the transcript's last cache write says which until the band's first reply (see Reopening an old session).
To remove the guess, set one of these. The band reads them the way Claude Code does, the first match in this order:
FORCE_PROMPT_CACHING_5M=1CLAUDE_CODE_PROMPT_CACHE_TTL=5m or 1h"promptCacheTtl": "5m" or "1h" in your settings.jsonENABLE_PROMPT_CACHING_1H=1In a terminal session on the hour's cache, Claude Code compacts a long conversation (200k tokens of context by default) that sits idle for about 54 minutes, shortly before its cache would expire. Your next message then writes a summary instead of the whole conversation (Claude Code's docs).
While that can happen, the cache's card says so until the 54-minute mark. The countdown and the re-warm price stay, since Claude Code also needs its feature flags and an awake computer to do it. Once it has compacted, the cache reads compacted, calm, with what the next message costs to write the summary; the next reply starts the countdown again. The band follows "idleCompaction": false, DISABLE_AUTO_COMPACT, DISABLE_COMPACT and CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS as Claude Code does. On the desktop, which doesn't compact while idle, neither shows.
It sends nothing. nextcost makes no network calls, never calls a model, and writes no files. The only web address in its code is the SVG namespace, http://www.w3.org/2000/svg, which every SVG icon it draws declares; nothing is fetched from it. The one way out a scanner can see is that it starts programs ($.process.run), since a program could reach anywhere. The five commands below start only git, tail and grep, each reading a local file or repository and handing its output to the band. None of them is a shell, none is given a script, and none touches the network.
It runs five read-only commands, each named in full where the code calls it, each given 3 seconds, and never while the band draws:
| Command | Where | Why |
|---|---|---|
git -c core.fsmonitor=false --no-optional-locks status --porcelain=v2 --branch | the session's project folder | the workspace line: branch, changes, ahead and behind. It never takes git's index lock. -c core.fsmonitor=false is git's own option for setting one value for this run: it stops a repository's config from making git start its fsmonitor program. It starts no shell. |
git rev-parse --path-format=absolute --git-dir --git-common-dir --show-toplevel | the same | whether the folder is a linked worktree, and of which repository |
| tail -c 1048576 <transcript> | that session's own transcript: at the path Claude Code gave when it said the session was resumed, else ~/.claude/projects/<project>/<session id>.jsonl, <project> being the project's path with every character but a letter or digit a dash | only when a conversation is resumed, or the band loads into one
hooks/register.tsx 650 lines1// The hooks: the one place that touches `$`. Each reads what the engine knows
2// into the pure modules, and ui.render hands drawBand a snapshot of them.
3
4import { atom, read, update } from 'claude-code'
5import type { ClassicEventOf, EngineInterface, ProcessRunResult, Register, Timer } from 'claude-code'
6import { drawBand } from './band'
7import { newCache } from './cache'
8import type { CacheModel, RecalledReply, Remembered, ResumedCache, Spend, TranscriptFacts } from './cache'
9import { idleCompactFrom, resolveTtl } from './cache-settings'
10import { ARGUMENT_HINT, commandPlan, parseCommand } from './command'
11import { contextUsed, hostZone, paintKey, readableZone } from './format'
12import { resolveGlyphs } from './glyphs'
13import { newInsights } from './insights'
14import type { Insights } from './insights'
15import { DARK, resolvePalette } from './palette'
16import type { Palette } from './palette'
17import {
18 LAYOUT_KEY,
19 LIMIT_SAMPLES_KEY,
20 RATES_KEY,
21 SESSIONS_KEY,
22 addSample,
23 asLimitSamples,
24 asSessions,
25 fromStore,
26 mergeSamples,
27 needsTranscript,
28 recallOf,
29 rememberRate,
30 rememberReply,
31 sameBucket,
32 sampleOf,
33 samplesUpTo,
34} from './memory'
35import type { Sample } from './memory'
36import { DEFAULT_LAYOUT, FIVE_HOUR, asLayoutName, limitOf, removedLayoutOf, resetsOf, snapshotOf } from './snapshot'
37import type { Glyphs, LayoutName } from './snapshot'
38import { NO_WARNINGS, contextToasts, forgetConversation, limitToasts } from './toast'
39import type { Warned } from './toast'
40import { READ_LIMIT, lastWriteTtl, sessionCostRecord, transcriptPath, transcriptSpend } from './transcript'
41import { homeRelative, parseGitState, splitPath } from './workspace'
42import type { Workspace } from './workspace'
43
44const isHidden = atom({ plugin: 'nextcost', key: 'isHidden' } as const, false)
45const isExpanded = atom({ plugin: 'nextcost', key: 'isExpanded' } as const, false)
46
47/** Long enough for a large repository's status or a transcript's tail,
48 * short enough that a hung command never holds a read open for long. */
49const PROCESS_TIMEOUT_MS = 3000
50
51/** A program's run, or undefined where it couldn't run or timed out. Each
52 * call names its whole command as fixed text, so a reader (and the
53 * directory's checks) sees every program the band runs where it runs it. */
54const settled = (run: Promise<ProcessRunResult>): Promise<ProcessRunResult | undefined> => run.catch(() => undefined)
55
56/** A conversation the engine said it resumed: its session, when the engine
57 * said so, what it said of its cache, where its transcript is, and once
58 * read, what that shows. */
59type Resume = Readonly<{
60 sessionId: string
61 at: number
62 cache: ResumedCache | undefined
63 path: string | undefined
64 transcript: TranscriptFacts | undefined
65}>
66
67/** What the band keeps between hooks. */
68type Band = {
69 /** The cache model, made anew at each session.start. */
70 cache: CacheModel
71 /** The insights, made anew beside the cache model. */
72 insights: Insights
73 palette: Readonly<Palette>
74 /** The layout the band draws in. */
75 layout: LayoutName
76 /** A removed layout the store still names, which the command mentions; else undefined. */
77 removed: string | undefined
78 /** The terminal's glyph tier, read from the environment at session.start. */
79 glyphs: Glyphs
80 /** Where auto-compaction runs, as the context breakdown last said; read
81 * at each measure, not on every redraw. Undefined when off or unknown. */
82 compactAt: number | undefined
83 /** The breakdown said auto-compaction is off. */
84 autoCompactOff: boolean
85 /** Where the session is: its project, home-relative, and git there. Read
86 * between redraws, never while drawing, since git takes a process. */
87 workspace: Workspace | undefined
88 /** The newest limit samples, a week's worth of buckets, every session's:
89 * read from the store at load and at each new 15-minute bucket, written
90 * once per bucket. */
91 samples: Sample[]
92 /** Reads begun, so one that ends after a newer one never overwrites it. */
93 reads: number
94 /** The conversation the engine last said it resumed, until it ends. The
95 * engine may say so before session.start, or while it runs, and its reset
96 * forgets it, so it outlives that reset and session.start notes it again. */
97 resume: Resume | undefined
98 /** What the band last drew, so the timer repaints only when it would change. */
99 lastPaintKey: string
100 /** The limits' resets the band last drew, epoch ms, so the timer repaints
101 * as their countdowns turn. */
102 resets: readonly number[]
103 /** Each toast's level reached, so it speaks once per crossing. */
104 warned: Warned
105 tick: Timer | undefined
106}
107
108/** What each session.start begins the band from: a new session's cache
109 * model and insights, no warning spoken, nothing drawn or read yet, and
110 * the default layout until the store is read. */
111const freshSession = (): Pick<Band, 'cache' | 'insights' | 'warned' | 'lastPaintKey' | 'resets' | 'workspace' | 'layout'> => ({
112 cache: newCache(),
113 insights: newInsights(),
114 warned: NO_WARNINGS,
115 lastPaintKey: '',
116 resets: [],
117 workspace: undefined,
118 layout: DEFAULT_LAYOUT,
119})
120
121/** The band. A reload starts it over with the module; session.start starts
122 * a session over with freshSession, and what it reads at once (the
123 * palette, the glyphs, the samples) it reads again; the rest carries on. */
124const band: Band = {
125 ...freshSession(),
126 palette: DARK,
127 removed: undefined,
128 glyphs: 'unicode',
129 compactAt: undefined,
130 autoCompactOff: false,
131 samples: [],
132 reads: 0,
133 resume: undefined,
134 tick: undefined,
135}
136
137/** Where `sessionId`'s transcript is: where the engine says, else where
138 * Claude Code keeps it for this project; undefined without either. */
139const transcriptFile = async ($: EngineInterface, sessionId: string, given: string | undefined): Promise<string | undefined> => {
140 if (given !== undefined) return given
141 const home = await $.env.get('HOME')
142 return home ? transcriptPath(home, await $.session.root(), sessionId) : undefined
143}
144
145/** A whole transcript, if it is small enough to read. */
146const readWhole = async ($: EngineInterface, path: string): Promise<string | undefined> => {
147 const stat = await $.fs.stat(path).catch(() => undefined)
148 if (stat === undefined || stat.size > READ_LIMIT) return undefined
149 const text = await $.fs.read(path).catch(() => undefined)
150 return typeof text === 'string' ? text : undefined
151}
152
153/** A transcript's end, where its last reply and cost record are: its last
154 * megabyte by `tail`, whatever its size; failing that, the whole file if it
155 * is small enough to read. */
156const transcriptEnd = async ($: EngineInterface, path: string): Promise<string | undefined> => {
157 // Each argument but the path is written out, so a reader sees the whole
158 // command here: 1048576 bytes is a megabyte, room for a long last reply.
159 const tail = await settled($.process.run(['tail', '-c', '1048576', path], { timeoutMs: PROCESS_TIMEOUT_MS }))
160 return tail?.exitCode === 0 ? tail.stdout : readWhole($, path)
161}
162
163/** What `sessionId`'s conversation spent before this process resumed it at
164 * `before`, off its transcript: its cost record, which `grep` finds at any
165 * size, and the replies logged after it, which `tail` reads from there.
166 * Where grep can't run, a transcript small enough to read is read whole.
167 * Undefined with no record, or no way to read one. */
168const spendBefore = async ($: EngineInterface, path: string, sessionId: string, before: number): Promise<Spend | undefined> => {
169 // `-a`: a line cut mid-character would otherwise make the file binary to
170 // grep, which then prints no lines and still exits 0.
171 // The pattern is COST_RECORD, written out for the same reason.
172 const found = await settled($.process.run(['grep', '-a', '-b', '-F', '"type":"cost-state"', path], { timeoutMs: PROCESS_TIMEOUT_MS }))
173 // grep exits 1 when nothing matches, 2 when it fails.
174 if (found?.exitCode === 1) return undefined
175 if (found?.exitCode !== 0) {
176 const whole = await readWhole($, path)
177 return whole === undefined ? undefined : transcriptSpend(whole, sessionId, before)
178 }
179 const record = found.isStdoutTruncated ? undefined : sessionCostRecord(found.stdout, sessionId)
180 if (record === undefined) return undefined
181 const rest = await settled($.process.run(['tail', '-c', `+${record.offset + 1}`, path], { timeoutMs: PROCESS_TIMEOUT_MS }))
182 // Replies past what one read holds are left out rather than half-read; the record still counts.
183 const tail = rest?.exitCode === 0 && !rest.isStdoutTruncated ? rest.stdout : ''
184 // The record is grep's own line and the replies follow its end, so a grep
185 // whose offset is the match's, not the line's (ugrep), reads the same.
186 const recordEnd = tail.indexOf('\n')
187 return transcriptSpend(recordEnd < 0 ? record.line : record.line + tail.slice(recordEnd), sessionId, before)
188}
189
190/** Recalls when `sessionId` last had a reply, the model it was billed
191 * under, and what a token costs there: the band's own store, then for what
192 * it leaves unknown, the end of the transcript `readEnd` reads. Recalled
193 * once per load or resume, and only those facts kept; the caller notes
194 * them. Never throws: unknown stays unknown. */
195const recallLastReply = async (
196 $: EngineInterface,
197 sessionId: string,
198 readEnd: () => Promise<string | undefined>,
199): Promise<RecalledReply | undefined> => {
200 try {
201 const model = await $.session.model()
202 const rates = await $.store.get(RATES_KEY)
203 const sessions = await $.store.get(SESSIONS_KEY)
204 const stored = fromStore({ sessions, rates }, sessionId, model)
205 return recallOf(stored, needsTranscript(stored) ? await readEnd() : undefined)
206 } catch {
207 return undefined // nothing to recall
208 }
209}
210
211/** Reads what a resumed conversation's transcript shows, keeps it with the
212 * resume and redraws; where the engine gave no idle time, recalls the last
213 * reply off the same read of its end. Callers don't wait on it, and it
214 * never throws: a read or recall that ends after the conversation ended is
215 * dropped, and failing, the ledger and the TTL stand. */
216const readResumed = async ($: EngineInterface, mine: Resume): Promise<void> => {
217 try {
218 const file = await transcriptFile($, mine.sessionId, mine.path)
219 const end = file === undefined ? Promise.resolve(undefined) : transcriptEnd($, file)
220 const [recalled, spend, tail] = await Promise.all([
221 mine.cache === undefined ? recallLastReply($, mine.sessionId, () => end) : undefined,
222 file === undefined ? undefined : spendBefore($, file, mine.sessionId, mine.at),
223 end,
224 ])
225 if (band.resume !== mine) return
226 const transcript = { spend, ttl: tail === undefined ? undefined : lastWriteTtl(tail) }
227 band.resume = { ...mine, transcript }
228 band.cache.recalled(recalled, transcript)
229 $.ui.invalidate('ui.render')
230 } catch {
231 // the ledger and the TTL stand
232 }
233}
234
235/** A conversation resumed or forked: never new, its cache as the engine
236 * judges it, or as the band recalls it where the engine doesn't say; then,
237 * off the hook, what its transcript shows. */
238const resumeConversation = async ($: EngineInterface, e: ClassicEventOf['classic.SessionStart']): Promise<void> => {
239 const idleSec = e.seconds_since_last_response
240 const now = await $.clock.now()
241 const resume: Resume = {
242 // The engine names the conversation it resumes; on a /resume, the
243 // process switches to it only after this hook.
244 sessionId: e.session_id || (await $.session.id()),
245 at: now,
246 cache:
247 idleSec === undefined
248 ? undefined
249 : { lastAt: now - idleSec * 1000, expired: e.prompt_cache_likely_expired, reWarmUsd: e.estimated_cache_write_usd },
250 // '' when the session keeps no local transcript.
251 path: e.transcript_path === '' ? undefined : e.transcript_path,
252 transcript: undefined,
253 }
254 band.resume = resume
255 band.cache.resumed(resume)
256 void readResumed($, resume)
257 $.ui.invalidate('ui.render')
258}
259
260/** Remembers this session's last reply, the model it was billed under and
261 * the rate its bill solves to, kept under that model's name, for when it is
262 * reopened or the band reloads. */
263const rememberTurn = async ($: EngineInterface, cache: Remembered): Promise<void> => {
264 try {
265 const id = await $.session.id()
266 await $.store.set(SESSIONS_KEY, rememberReply(asSessions(await $.store.get(SESSIONS_KEY)), id, cache.lastAt, cache.billed))
267 const rate = cache.rate
268 if (rate !== null) await $.store.set(RATES_KEY, rememberRate(await $.store.get(RATES_KEY), cache.billed ?? cache.priceModel ?? (await $.session.model()), rate))
269 } catch {
270 // memory is a convenience; the band works without it
271 }
272}
273
274/** Reads the project and git into `band.workspace`, then redraws. It never throws:
275 * outside a repository, or with git missing or slow, the band shows the
276 * path alone. Callers don't wait on it. */
277const readWorkspace = async ($: EngineInterface): Promise<void> => {
278 const mine = ++band.reads
279 try {
280 const root = await $.session.root()
281 const home = await $.env.get('HOME')
282 const stdoutOf = async (run: Promise<ProcessRunResult>): Promise<string | undefined> => {
283 const r = await settled(run)
284 return r?.exitCode === 0 ? r.stdout : undefined
285 }
286 const [status, dirs, repo] = await Promise.all([
287 // Status without the index lock, so the band never blocks a commit,
288 // and without a repository's own fsmonitor, a program its config could
289 // name. Untracked files follow the user's own setting.
290 stdoutOf($.process.run(['git', '-c', 'core.fsmonitor=false', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], { cwd: root, timeoutMs: PROCESS_TIMEOUT_MS })),
291 // Three lines: git-dir, common-dir, toplevel.
292 stdoutOf($.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'], { cwd: root, timeoutMs: PROCESS_TIMEOUT_MS })),
293 $.session.repo().catch(() => null),
294 ])
295 if (mine !== band.reads) return
296 const path = homeRelative(root, home)
297 const last = band.workspace
298 band.workspace = {
299 path,
300 // A status that failed or timed out keeps the last good reading of the
301 // same project, so a slow repository doesn't flicker to the path alone.
302 git: status === undefined ? (last?.path === path ? last.git : undefined) : parseGitState(status, dirs ?? ''),
303 // A linked worktree's main repository, by its folder's name.
304 repoName: repo === null ? undefined : splitPath(repo.root).name,
305 }
306 $.ui.invalidate('ui.render')
307 } catch {
308 // keep the last reading
309 }
310}
311
312/** The samples every session has stored; undefined when the read fails. */
313const storedSamples = ($: EngineInterface): Promise<Sample[] | undefined> =>
314 $.store.get(LIMIT_SAMPLES_KEY).then(asLimitSamples, () => undefined)
315
316/** Keeps a limit sample: within the last one's bucket, in memory alone; at a
317 * new bucket, merged with what other sessions stored, then written once.
318 * Never throws: a store that fails leaves the samples in memory. */
319const noteSample = async ($: EngineInterface, sample: Sample): Promise<void> => {
320 const last = band.samples[band.samples.length - 1]
321 const stored = last !== undefined && sameBucket(last, sample) ? undefined : await storedSamples($)
322 if (stored === undefined) {
323 // The same bucket, or a failed read, after which a write would overwrite
324 // what other sessions stored since: memory alone until the next bucket.
325 band.samples = addSample(band.samples, sample)
326 return
327 }
328 band.samples = addSample(samplesUpTo(mergeSamples(band.samples, stored), sample.at), sample)
329 await $.store.set(LIMIT_SAMPLES_KEY, band.samples).catch(() => undefined)
330}
331
332/** What the session has cost so far, if the host keeps a ledger; undefined
333 * when it has none, or the read fails. */
334const ledgerUsd = async ($: EngineInterface): Promise<number | undefined> =>
335 (await $.session.usage().catch(() => undefined))?.cost?.usd
336
337/** The stored layout, or chips: a name this version doesn't know, or a value
338 * that isn't a name, draws chips. A read that fails keeps the layout drawn.
339 * Never throws. */
340const readLayout = async ($: EngineInterface): Promise<void> => {
341 const read = await $.store.get(LAYOUT_KEY).then(
342 stored => ({ stored }),
343 () => undefined,
344 )
345 if (read === undefined) return
346 band.layout = asLayoutName(read.stored) ?? DEFAULT_LAYOUT
347 band.removed = removedLayoutOf(read.stored)
348}
349
350/** What /nextcost, or its old name /usage-band, does with `args`: the plan
351 * command.ts makes of it, carried out, and what it answers. */
352const runCommand = async ($: EngineInterface, args: string): Promise<{ text: string }> => {
353 const plan = commandPlan(parseCommand(args), { layout: band.layout, removed: band.removed, hidden: await read($, isHidden) })
354 let text = plan.reply
355 if (plan.layout !== undefined) {
356 const { name } = plan.layout
357 band.layout = name
358 band.removed = undefined
359 // Remembered for the next session. A store that fails leaves this one
360 // switched until the next turn reads the store back, and the reply says so.
361 const saved = await $.store.set(LAYOUT_KEY, name).then(
362 () => true,
363 () => false,
364 )
365 if (!saved) text = plan.layout.unsaved
366 }
367 const { expanded, hidden } = plan
368 if (expanded !== undefined) await update($, isExpanded, () => expanded)
369 if (hidden !== undefined) await update($, isHidden, () => hidden)
370 if (plan.layout !== undefined) $.ui.invalidate('ui.render')
371 if (plan.readWorkspace) void readWorkspace($)
372 return { text }
373}
374
375/** Starts the one timer, in place of any before it, repainting only when
376 * the drawing would differ: every minute (the clocks, the battery, the
377 * average's pace), and as each countdown turns, every second where it
378 * shows seconds. */
379const startTicker = ($: EngineInterface): void => {
380 band.tick?.cancel()
381 band.tick = $.clock.every(1000, () => {
382 void (async () => {
383 const now = await $.clock.now()
384 const key = paintKey(now, {
385 cacheLeft: band.cache.leftMs(now),
386 idleMs: band.cache.idleMs(now),
387 etaMs: band.insights.fiveHourEtaMs(now),
388 resets: band.resets,
389 zone: readableZone(hostZone, now),
390 })
391 if (key !== band.lastPaintKey) {
392 band.lastPaintKey = key
393 $.ui.invalidate('ui.render')
394 }
395 })().catch(() => undefined)
396 })
397}
398
399/** Registers /nextcost, and its name before 0.18.0, kept so a habit or a
400 * note still works. */
401const registerCommands = ($: EngineInterface): void => {
402 $.command.register({
403 name: 'nextcost',
404 description: 'Show, hide, expand, collapse or restyle nextcost, what your next message costs',
405 argumentHint: ARGUMENT_HINT,
406 })
407 $.command.register({
408 name: 'usage-band',
409 description: "nextcost's old name: the same as /nextcost",
410 argumentHint: ARGUMENT_HINT,
411 })
412}
413
414/** Reads the palette and the glyph tier from the environment. One literal
415 * name a read: the engine lists the variables a module reads. Each setting
416 * by its name, else by its name before 0.18.0. */
417const readAppearance = async ($: EngineInterface): Promise<void> => {
418 const appearance = (await $.env.get('NEXTCOST_APPEARANCE')) ?? (await $.env.get('CC_BAND_APPEARANCE'))
419 band.palette = resolvePalette(appearance?.toLowerCase(), await $.env.get('NO_COLOR'))
420 band.glyphs = resolveGlyphs({
421 NEXTCOST_GLYPHS: (await $.env.get('NEXTCOST_GLYPHS')) ?? (await $.env.get('CC_BAND_GLYPHS')),
422 LC_ALL: await $.env.get('LC_ALL'),
423 LC_CTYPE: await $.env.get('LC_CTYPE'),
424 LANG: await $.env.get('LANG'),
425 })
426}
427
428/** Reads what the environment and settings say of the cache, for a
429 * `terminal` session or not, and hands the cache model the TTL they pin
430 * and where idle compaction runs from. */
431const readCacheSettings = async ($: EngineInterface, terminal: boolean): Promise<void> => {
432 // Only these two keys are taken; the rest, `env` included, is dropped here.
433 const { promptCacheTtl, idleCompaction } = (await $.settings.read().catch(() => undefined)) ?? {}
434 const pinned = resolveTtl({
435 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M'),
436 chosen: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
437 setting: promptCacheTtl,
438 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
439 })
440 const idleFrom = idleCompactFrom({
441 terminal,
442 setting: idleCompaction,
443 disableAuto: await $.env.get('DISABLE_AUTO_COMPACT'),
444 disableAll: await $.env.get('DISABLE_COMPACT'),
445 minTokens: await $.env.get('CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS'),
446 })
447 band.cache.configured({ ttl: pinned, idleCompactFrom: idleFrom })
448}
449
450/** Recalls session `id`'s last reply into the cache model, off the store or
451 * the end of its transcript: where a resume names it, else where Claude
452 * Code keeps it. */
453const recallOnLoad = async ($: EngineInterface, id: string): Promise<void> => {
454 const path = band.resume?.path
455 const readEnd = async () => {
456 const file = await transcriptFile($, id, path)
457 return file === undefined ? undefined : transcriptEnd($, file)
458 }
459 band.cache.recalled(await recallLastReply($, id, readEnd))
460}
461
462export const register: Register = on => {
463 on('session.start', async ($, e, next) => {
464 // The timer and the commands come first, so a read below that waits or
465 // fails never leaves the band without them.
466 startTicker($)
467 registerCommands($)
468
469 const id = await $.session.id().catch(() => undefined)
470 Object.assign(band, freshSession())
471 // A resume of another session is no longer news. It is dropped in the
472 // same step as the reset, so no read of it can land in between.
473 if (band.resume?.sessionId !== id) band.resume = undefined
474 const now = await $.clock.now()
475 band.samples = samplesUpTo((await storedSamples($)) ?? [], now)
476 band.reads++ // any read still out began before this load
477 // A read that fails keeps the layout drawn: none yet, so chips.
478 await readLayout($)
479 // The settings come before the recall, which may wait on a transcript,
480 // so a session.start that runs long has its palette, glyphs and TTL.
481 await readAppearance($)
482 await readCacheSettings($, e.surface === 'terminal' && e.isInteractive)
483 const priceModel = await $.session.model().catch(() => undefined)
484 // A resume of this session, said before this load or while it runs, is
485 // noted again after the reset.
486 if (band.resume !== undefined) band.cache.resumed(band.resume)
487 band.cache.loaded({ priceModel, ledger: await ledgerUsd($) })
488 void readWorkspace($)
489 // Loaded mid-conversation, the band has seen no reply: recall the last,
490 // unless the engine said when it was.
491 if (id !== undefined && band.cache.wantsRecall()) await recallOnLoad($, id)
492 return next(e)
493 }).catch(($, e, next) => next(e)) // a failure here must never stop a session starting
494
495 // /clear and resume end the conversation but not the process, and no
496 // session.start follows, so the next conversation starts from here. A
497 // resume's is under way already, and its SessionStart says what it was.
498 on('session.end', async ($, e, next) => {
499 band.resume = undefined // and any read of it still out is dropped
500 const costNow = (await ledgerUsd($)) ?? 0
501 band.cache.conversationEnded(costNow, e.reason === 'resume')
502 band.insights.conversationEnded()
503 band.warned = forgetConversation(band.warned)
504 band.lastPaintKey = ''
505 // A resume may be another project.
506 void readWorkspace($)
507 $.ui.invalidate('ui.render')
508 return next(e)
509 })
510
511 // A conversation resumed at launch (`claude --resume`, the desktop opening
512 // a past session) or by /resume: the ledger may read $0, but it is no new one.
513 on('classic.SessionStart', async ($, e, next) => {
514 if (e.agent_id === undefined && (e.source === 'resume' || e.source === 'fork')) await resumeConversation($, e)
515 return next(e)
516 }).catch(($, e, next) => next(e)) // a failure here must never stop a session starting
517
518 on('turn.start', async ($, e, next) => {
519 // Since 2.1.299 a subagent's turn raises turn.start too, with its agentId
520 // (read through `in`: older engines' types don't declare it). Its run is
521 // part of the main turn that spawned it, which keeps the start cost.
522 if ('agentId' in e && e.agentId !== undefined) return next(e)
523 // /model may have switched what the session's tokens are priced at.
524 const priceModel = await $.session.model().catch(() => undefined)
525 const cost = await ledgerUsd($)
526 band.insights.noteTurnStart(e.turnId, cost)
527 band.cache.turnStarted({ priceModel, ledger: cost })
528 return next(e)
529 })
530
531 // Main-loop turns only: a subagent's run is part of the turn that spawned it.
532 on('turn.complete', async ($, e, next) => {
533 const result = await next(e)
534 if (e.agentId === undefined) {
535 const usage = await $.session.usage().catch(() => undefined)
536 const cost = usage?.cost?.usd
537 const spent = band.insights.noteTurnEnd(e.turnId, cost)
538 // Taken every turn, so a rebuild never marks a later turn's cost.
539 const ended = band.cache.turnEnded(cost)
540 if (spent !== null) band.insights.pushCost(spent, ended.rebuilt)
541 const used = usage === undefined ? undefined : contextUsed(usage.context)
542 if (used !== undefined) band.insights.pushContext(used)
543 if (ended.remember !== undefined) void rememberTurn($, ended.remember)
544 const now = await $.clock.now()
545 const sample = usage === undefined ? undefined : sampleOf(now, usage.rateLimits.map(limitOf))
546 if (sample !== undefined) await noteSample($, sample)
547 // Another session may have chosen a layout since.
548 await readLayout($)
549 // A turn may have switched branch, committed or moved the session.
550 void readWorkspace($)
551 $.ui.invalidate('ui.render')
552 }
553 return result
554 })
555
556 on('turn.step', async function* ($, e, next) {
557 // The cache's TTL runs from when the request is sent, not when its reply ends.
558 const sentAt = await $.clock.now()
559 const result = yield* next(e)
560 const isMain = e.agentId === undefined
561 if (result?.usage) {
562 band.cache.responded(result.usage, sentAt, isMain)
563 band.cache.ledgerRead(await ledgerUsd($))
564 $.ui.invalidate('ui.render')
565 }
566 if (isMain && result?.stopReason === 'compaction') band.cache.compacted({ now: sentAt })
567 return result
568 })
569
570 // A compaction of the main conversation rebuilds the cache on purpose.
571 on('session.compact', async ($, e, next) => {
572 const result = await next(e)
573 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
574 band.cache.compacted({ now: await $.clock.now(), sizeAfter: result.tokensAfter, manual: e.trigger === 'manual' })
575 $.ui.invalidate('ui.render')
576 }
577 return result
578 }).catch(($, e, next) => next(e)) // a failure here must never stop a compaction
579
580 on('session.measure', async ($, e, next) => {
581 const now = await $.clock.now()
582 for (const limit of e.rateLimits.map(limitOf)) if (limit.kind === FIVE_HOUR) band.insights.noteFiveHour(now, limit.percentUsed, limit.resetsAt)
583 const limits = limitToasts(band.warned, e.rateLimits)
584 band.warned = limits.warned
585 for (const text of limits.say) $.ui.toast(text)
586
587 // Local and token-free, but it can fail; the last answer stands until a new one.
588 try {
589 const breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
590 if (breakdown !== undefined) {
591 // On without a threshold says nothing about where: leave it unknown.
592 band.autoCompactOff = !breakdown.isAutoCompactEnabled
593 band.compactAt = breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
594 }
595 } catch {
596 // keep the last known setting
597 }
598
599 const context = contextToasts(band.warned, { used: contextUsed(e.context), window: e.context.window, compactAt: band.compactAt, autoCompactOff: band.autoCompactOff })
600 band.warned = context.warned
601 for (const text of context.say) $.ui.toast(text)
602
603 $.ui.invalidate('ui.render')
604 return next(e)
605 })
606
607 on('command.run', { command: 'nextcost' }, async ($, e) => runCommand($, e.args))
608 // The old name, the same command.
609 on('command.run', { command: 'usage-band' }, async ($, e) => runCommand($, e.args))
610
611 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
612 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
613
614 // A failed read steps aside rather than break the prompt.
615 const usage = await $.session.usage().catch(() => undefined)
616 if (usage === undefined) return next(e)
617 const now = await $.clock.now()
618 const { cache, costUsd } = band.cache.view({ now, ledger: usage.cost?.usd, contextTokens: contextUsed(usage.context) ?? 0, autoCompactOff: band.autoCompactOff })
619 const snap = snapshotOf({
620 usage,
621 now,
622 mount: { surface: e.surface, columns: e.props.bodyColumns, maxRows: e.props.maxRows, isWorking: e.props.isWorking },
623 expanded: await read($, isExpanded),
624 band,
625 cache,
626 costUsd,
627 history: band.insights.trails(),
628 etaMs: band.insights.fiveHourEtaMs(now),
629 // Read at each instant drawn, so a daylight-saving change shows by
630 // the next minute's repaint, with no reply needed.
631 zone: readableZone(hostZone, now),
632 })
633 band.resets = resetsOf(snap)
634
635 return drawBand(
636 $.ui.resolve(e),
637 snap,
638 {
639 toggleExpanded: async () => {
640 // Opening reads git, so the cards never show a stale branch.
641 if (await update($, isExpanded, current => !current)) void readWorkspace($)
642 },
643 hide: async () => {
644 await update($, isHidden, () => true)
645 },
646 },
647 )
648 }).catch(($, e, next) => next(e)) // a band that can't be drawn steps aside, never breaks the prompt
649}
650hooks/band.tsx 48 lines1// The band, drawn: a pure function of the snapshot register.tsx reads.
2// It never sees `$`, so everything it shows arrives in the snapshot. It
3// builds the kit and the readings, and draws the chosen layout from them,
4// or chips in its place.
5
6import type { ElementTable, RenderElement } from 'claude-code'
7import { asciiTree } from './glyphs'
8import { makeKit } from './kit'
9import { readingsOf } from './reading'
10import { DEFAULT_LAYOUT } from './snapshot'
11import type { BandActions, BandSnapshot, LayoutName } from './snapshot'
12import { VIEWS } from './views/index'
13import type { View } from './views/view'
14import { drawView } from './views/frame'
15
16/** Below this, every layout draws chips (three-layouts spec §1); a layout
17 * may need more. */
18const CHIPS_BELOW = 40
19
20/** The layout the band draws at `columns`: the one chosen, or chips below
21 * CHIPS_BELOW or the chosen view's own fewest columns. */
22export const layoutAt = (layout: LayoutName, columns: number, views: Readonly<Record<LayoutName, View>> = VIEWS): LayoutName =>
23 columns < Math.max(CHIPS_BELOW, views[layout].minColumns ?? 0) ? DEFAULT_LAYOUT : layout
24
25/** `views` is for a test's registry; the band draws from VIEWS. */
26export const drawBand = (
27 el: ElementTable,
28 snap: BandSnapshot,
29 act: BandActions,
30 views: Readonly<Record<LayoutName, View>> = VIEWS,
31): RenderElement => {
32 const draw = (layout: LayoutName): RenderElement => {
33 const kit = makeKit(el, snap)
34 const read = readingsOf(snap)
35 const tree = drawView(views[layout], kit, read, act)
36 // Mapped last: the squeeze measured the row in the band's own glyphs, and
37 // no mapping widens one.
38 return kit.glyphs === 'ascii' ? asciiTree(tree) : tree
39 }
40 try {
41 return draw(layoutAt(snap.layout, snap.columns, views))
42 } catch {
43 // A layout, its kit or its readings that throw never take the band
44 // down: chips stands in, and where it throws too, the hook steps aside.
45 return draw(DEFAULT_LAYOUT)
46 }
47}
48hooks/cache.ts 621 lines1// The prompt cache, as the band models it from each response's token counts,
2// and what it means for the band: its mood, its charge, its last call. One
3// model per session, made at session.start and fed each hook's facts in that
4// hook's own shape; the order they must be applied in is kept here.
5
6import type { ModelUsage } from 'claude-code'
7import type { Tone } from './facts'
8import { LAST_CALL_MS, MINUTE_MS, SOON_MS, fmtEstimate, fmtTokens } from './format'
9import { NO_TOKENS, WRITE_MULT, addTokens, modelName, readMultiplier, tierFactor, weightedTokens } from './prices'
10import type { TokenCounts } from './prices'
11
12export type Ttl = '5m' | '1h'
13export const TTL_MS: Readonly<Record<Ttl, number>> = { '5m': 5 * MINUTE_MS, '1h': 60 * MINUTE_MS }
14
15// A response that read this much less than the cache held, both in tokens and
16// as a share of it, missed the cache.
17const MISS_MIN_TOKENS = 2000
18const MISS_MIN_SHARE = 0.05
19
20/** Claude Code compacts a conversation left idle on the hour's cache about
21 * this long after its last request, before the cache runs out. */
22export const IDLE_COMPACT_MS = 54 * MINUTE_MS
23/** A compaction this long after the last request, with no turn under way, is
24 * an idle one: an auto-compaction comes as a turn starts, not half an hour on. */
25const IDLE_GAP_MS = 30 * MINUTE_MS
26
27/** What a conversation has spent: its dollars and its tokens. */
28export type Spend = Readonly<{ usd: number; tokens: TokenCounts }>
29
30/** What the engine says of a resumed conversation's cache: when its last
31 * reply was, whether its cache has likely expired, and what re-caching it
32 * costs. */
33export type ResumedCache = Readonly<{ lastAt: number; expired: boolean | undefined; reWarmUsd: number | undefined }>
34
35/** What a resumed conversation's transcript shows: its spend before this
36 * process, and the TTL of its last cache write. */
37export type TranscriptFacts = Readonly<{ spend: Spend | undefined; ttl: Ttl | undefined }>
38
39/** What the band recalls of a conversation's last reply, before its own:
40 * when it was, the base rate per token on its model, and the model it was
41 * billed under, where its transcript names one. */
42export type RecalledReply = Readonly<{ lastAt: number | undefined; rate: number | null; billed: string | undefined }>
43
44/** The cache as the model measured it, the snapshot's to carry to the band. */
45export type CacheView = Readonly<{
46 requests: number
47 /** The cache's time left: 0 once cold. */
48 leftMs: number
49 /** When the cache goes or went cold, epoch ms; null when not known. */
50 coldAt: number | null
51 ttl: Ttl
52 /** The TTL is known, not assumed: pinned by the environment, or seen on
53 * the transcript's last cache write before this band's first reply. */
54 ttlPinned: boolean
55 window: number
56 hitRatio: number | null
57 misses: number
58 reWarmUsd: number | null
59 /** What reading from the cache saved against full input price. */
60 savedUsd: number | null
61 /** A cache read's price against input, on the model in force. */
62 readShare: number
63 /** The conversation is known to start here; else, before its first
64 * reply, the band has not measured the cache yet. */
65 fresh: boolean
66 /** Claude Code compacted the conversation while it sat idle: the cache
67 * is gone, and the next message writes the summary. */
68 compacted: boolean
69 /** Claude Code may compact this conversation when it sits idle on the
70 * hour's cache: a terminal session, idle compaction on, and the context
71 * past its threshold. */
72 idleCompacts: boolean
73 /** Before the first reply the band saw, the cache is as recalled: from
74 * the session's last reply, remembered or read off its transcript. */
75 recalled: boolean
76 /** How long since that recalled reply. */
77 idleMs: number | null
78 /** A resumed conversation's spend and tokens from before this process
79 * are counted, as its transcript records them. */
80 recovered: boolean
81 /** Every token since the conversation began, subagents included. */
82 tokens: Readonly<{ sent: number; back: number; cached: number }>
83}>
84
85type CacheState = {
86 ttl: Ttl
87 /** Set by the environment, so never inferred. */
88 ttlPinned: boolean
89 /** The TTL the transcript's last cache write was made at, until this
90 * band's first reply: it speaks for the recalled cache, never for the next
91 * request, and the environment's pin still wins. */
92 ttlSeen: Ttl | undefined
93 /** The context, in tokens, from which Claude Code compacts this session
94 * when it sits idle; undefined when it never does (idleCompactFrom). */
95 idleCompactFrom: number | undefined
96 /** A main-loop turn is under way: a compaction then is no idle one. */
97 turnOpen: boolean
98 /** Main-loop requests this conversation. */
99 requests: number
100 /** Every token since the conversation began, subagents included. */
101 tokens: TokenCounts
102 /** What requests in an upper price tier weigh beyond their tokens at the
103 * base rate, so the rate solved is still the lower tier's. */
104 premium: number
105 /** Cache reads counted at the lower tier's prices: an upper-tier request's
106 * reads count `factor` times, so what reading saved is priced as billed. */
107 tieredReads: number
108 /** Requests that missed a cache that should have been warm. */
109 misses: number
110 /** When the last main-loop request was sent. */
111 lastAt: number
112 /** The last main-loop request's whole window, its response included. */
113 window: number
114 /** What that request left in the cache; the response is only written on
115 * the next request, never read. */
116 cached: number
117 /** The next request rebuilds the cache on purpose (a compaction). */
118 rebuilding: boolean
119 /** When Claude Code compacted the conversation while it sat idle: the
120 * cache it held is given up, and the next request writes the summary. */
121 compactedAt: number | undefined
122 /** The model the last main-loop request ran on; a switch rebuilds the cache. */
123 model: string | undefined
124 /** Since the turn's end last read it, a main-loop request rebuilt a cache
125 * that existed. */
126 lastRebuilt: boolean
127 /** The ledger when this conversation's first turn began, so spend from
128 * before it (a /clear, a resume, a reload) can't inflate the rate the
129 * re-warm price is solved from. */
130 costBase: number
131 baselined: boolean
132 /** The conversation is known to start here (a new session, a /clear), so
133 * its cache is warming; after a reload mid-conversation it is unmeasured. */
134 knownFresh: boolean
135 /** The engine said this conversation was resumed, so it never starts here,
136 * whatever the ledger says. */
137 resumed: boolean
138 /** What the engine said of the resumed conversation's cache, when it said;
139 * before this band's first reply it stands over anything recalled. */
140 resumedCache: ResumedCache | undefined
141 /** Before this band's first reply: when the conversation's last reply was,
142 * and the base rate per token on its model, as recalled. */
143 recall: Readonly<{ lastAt: number; rate: number | null }> | undefined
144 /** A resumed conversation's spend before this process, as its transcript
145 * records it. */
146 prior: Spend | undefined
147 /** The model /model has in force, as it names it: perhaps an alias. */
148 priceModel: string | undefined
149 /** The model the main loop's last reply was billed under, as the API names
150 * it. Where known, the tokens are priced at it. */
151 billedModel: string | undefined
152}
153
154const INITIAL: Readonly<CacheState> = {
155 ttl: '1h',
156 ttlPinned: false,
157 ttlSeen: undefined,
158 idleCompactFrom: undefined,
159 turnOpen: false,
160 requests: 0,
161 tokens: NO_TOKENS,
162 premium: 0,
163 tieredReads: 0,
164 misses: 0,
165 lastAt: 0,
166 window: 0,
167 cached: 0,
168 rebuilding: false,
169 compactedAt: undefined,
170 model: undefined,
171 lastRebuilt: false,
172 costBase: 0,
173 baselined: false,
174 knownFresh: false,
175 resumed: false,
176 resumedCache: undefined,
177 recall: undefined,
178 prior: undefined,
179 priceModel: undefined,
180 billedModel: undefined,
181}
182
183// ---- what the state says ----------------------------------------------------
184
185/** When the conversation's last reply was, before this band's own: as the
186 * engine said on a resume, else as recalled. */
187const recalledAt = (s: CacheState): number | undefined => s.resumedCache?.lastAt ?? s.recall?.lastAt
188
189/** Whether the cache stands as recalled: no reply seen yet, a conversation
190 * that didn't start here, and something to recall. */
191const isRecalled = (s: CacheState): boolean => s.requests === 0 && !s.knownFresh && recalledAt(s) !== undefined
192
193/** The TTL the cache stands at: the pin, else one seen on the transcript,
194 * else the one assumed or inferred. */
195const effectiveTtl = (s: CacheState): Ttl => (s.ttlPinned ? s.ttl : (s.ttlSeen ?? s.ttl))
196
197/** The model the tokens are priced at: the one replies are billed under, else
198 * what /model names, which may be an alias such as `opus[1m]`. */
199const pricedModel = (s: CacheState): string | undefined => s.billedModel ?? (s.priceModel === undefined ? undefined : modelName(s.priceModel))
200
201/** What the session has cost, to show: the ledger, or for a resumed
202 * conversation the larger of the ledger and its transcript's total, with
203 * the ledger's growth since the conversation's first turn on top. Never
204 * both summed: a host may restore the ledger on a resume, or may not, and
205 * may restore it only after the transcript is read, so the growth counts
206 * from the baseline that turn takes, never from the ledger at the read. */
207const spentUsd = (s: CacheState, ledgerNow: number | undefined): number => {
208 const ledger = ledgerNow ?? 0
209 const prior = s.prior
210 if (prior === undefined) return ledger
211 const growth = s.baselined ? ledger - s.costBase : 0
212 return Math.max(ledger, prior.usd + growth)
213}
214
215/** Every token since the conversation began: a resumed one's earlier tokens
216 * with this process's. */
217const allTokens = (s: CacheState): TokenCounts => (s.prior === undefined ? s.tokens : addTokens(s.tokens, s.prior.tokens))
218
219/** The base rate per token, in dollars.
220 *
221 * No pricing table is available to a mod, so the rate is solved from the
222 * session's own bill since its baseline, which leaves one unknown:
223 *
224 * cost - costBase = r * (weightedTokens(tokens) + premium)
225 *
226 * where premium weighs each upper-tier request again, so r is the lower
227 * tier's rate on a model with two.
228 *
229 * It self-calibrates to whatever model and plan are in force, and it is an
230 * estimate on top of an estimate (the session cost is itself computed at list
231 * price), so what it prices is always shown with a "~". */
232const ratePerToken = (s: CacheState, sessionCost: number | undefined): number | null => {
233 if (sessionCost === undefined) return null
234 const weighted = weightedTokens(s.tokens, pricedModel(s)) + s.premium
235 const billed = sessionCost - s.costBase
236 return weighted > 0 && billed > 0 ? billed / weighted : null
237}
238
239/** What writing `tokens` to the cache costs at a base `rate` per token: the
240 * re-warm's prompt is the whole window, which sets its tier. */
241const reWarmAt = (s: CacheState, rate: number, tokens: number): number => rate * WRITE_MULT * tokens * tierFactor(pricedModel(s), tokens)
242
243/** When a measured cache goes or went cold: a lifetime after the last reply
244 * this band saw; none before one. */
245const measuredColdAt = (s: CacheState): number | null => (s.requests > 0 ? s.lastAt + TTL_MS[effectiveTtl(s)] : null)
246
247/** When the cache goes or went cold: measured from the last reply, else
248 * recalled; null with nothing to go by, or a resume the engine called expired. */
249const coldAtOf = (s: CacheState): number | null => {
250 if (s.compactedAt !== undefined) return s.compactedAt
251 const measured = measuredColdAt(s)
252 if (measured !== null) return measured
253 if (s.resumedCache?.expired === true) return null
254 const recalled = isRecalled(s) ? recalledAt(s) : undefined
255 return recalled === undefined ? null : recalled + TTL_MS[effectiveTtl(s)]
256}
257
258/** The cache's time left: from the last reply this band saw, else the one
259 * it recalls, none if the engine called it expired; a full lifetime before
260 * either. */
261const leftMsOf = (s: CacheState, now: number): number => {
262 if (s.compactedAt !== undefined) return 0
263 if (s.requests === 0 && s.resumedCache?.expired === true) return 0
264 const at = coldAtOf(s)
265 return at === null ? TTL_MS[effectiveTtl(s)] : Math.max(0, at - now)
266}
267
268/** How long the recalled reply has sat idle at `now`; null unless the
269 * cache stands as recalled. */
270const idleMsOf = (s: CacheState, now: number): number | null => {
271 const lastAt = recalledAt(s)
272 return isRecalled(s) && lastAt !== undefined ? now - lastAt : null
273}
274
275/** The cache as the band shows it, at `now`: measured once a reply has been
276 * seen, recalled before that. `contextTokens` is the context as it stands,
277 * which a recalled cold cache would rebuild. */
278const viewOf = (s: CacheState, now: number, sessionCost: number | undefined, contextTokens: number, autoCompactOff: boolean): CacheView => {
279 const recalled = isRecalled(s)
280 const recallRate = s.recall?.rate ?? null
281 // A resume's price is the engine's own; else it is solved from the rate recalled.
282 const recalledPrice = s.resumedCache?.reWarmUsd ?? (recallRate === null ? null : reWarmAt(s, recallRate, contextTokens))
283 const rate = ratePerToken(s, sessionCost)
284 const tokens = allTokens(s)
285 const input = tokens.read + tokens.written + tokens.uncached
286 const share = readMultiplier(pricedModel(s))
287 return {
288 requests: s.requests,
289 leftMs: leftMsOf(s, now),
290 coldAt: coldAtOf(s),
291 ttl: effectiveTtl(s),
292 // Known, not assumed: pinned, or seen on the transcript's last cache write.
293 ttlPinned: s.ttlPinned || s.ttlSeen !== undefined,
294 window: recalled ? contextTokens : s.window,
295 hitRatio: input > 0 ? tokens.read / input : null,
296 misses: s.misses,
297 // A measured cold cache rebuilds with a cache write of the whole window.
298 reWarmUsd: recalled ? recalledPrice : rate === null ? null : reWarmAt(s, rate, s.window),
299 recalled,
300 idleMs: idleMsOf(s, now),
301 // Every cache read saved the rest of the base rate against full input price.
302 savedUsd: rate === null || tokens.read <= 0 ? null : rate * (1 - share) * (s.tieredReads + (s.prior?.tokens.read ?? 0)),
303 readShare: share,
304 fresh: s.knownFresh,
305 compacted: s.compactedAt !== undefined,
306 idleCompacts: s.idleCompactFrom !== undefined && !autoCompactOff && contextTokens >= s.idleCompactFrom,
307 recovered: s.prior !== undefined,
308 tokens: { sent: tokens.uncached + tokens.written, back: tokens.output, cached: tokens.read },
309 }
310}
311
312// ---- what changes it --------------------------------------------------------
313
314/** A resumed conversation didn't start here, whatever the ledger says; what
315 * the engine knows of its cache, when it says, stands over anything
316 * recalled. A resume's facts replace any noted before them. */
317const noteResume = (s: CacheState, said: ResumedCache | undefined): void => {
318 s.knownFresh = false
319 s.resumed = true
320 s.resumedCache = said
321 s.prior = undefined
322 s.ttlSeen = undefined
323}
324
325/** What a resumed conversation's transcript shows: its spend before this
326 * process, and the TTL of its last cache write, which speaks for the cache
327 * until this band's first reply. */
328const noteTranscript = (s: CacheState, facts: TranscriptFacts): void => {
329 if (facts.spend !== undefined) s.prior = facts.spend
330 if (facts.ttl !== undefined && s.requests === 0) s.ttlSeen = facts.ttl
331}
332
333/** The engine may reset the ledger on /clear: once it reads below the
334 * baseline, it counts this conversation alone, so the baseline is 0. */
335const noteLedger = (s: CacheState, costNow: number | undefined): void => {
336 if (costNow !== undefined && costNow < s.costBase) s.costBase = 0
337}
338
339/** A compaction replaces the conversation with a summary: the next request
340 * rebuilds the cache on purpose, at the summary's size. */
341const noteCompaction = (s: CacheState, sizeAfter: number | undefined): void => {
342 s.rebuilding = true
343 if (sizeAfter !== undefined) {
344 s.window = sizeAfter
345 s.cached = 0
346 }
347}
348
349/** A response's tokens toward the bill: every response's, a subagent's
350 * included, since the session's bill includes every subagent and the rate
351 * is solved from it. */
352const billResponse = (s: CacheState, usage: Response): void => {
353 const hit = usage.cache_read_input_tokens
354 const written = usage.cache_creation_input_tokens
355 const uncached = usage.input_tokens
356 const request = { uncached, written, read: hit, output: usage.output_tokens }
357 s.tokens = addTokens(s.tokens, request)
358 // A request is priced by its whole prompt, cache reads and writes included.
359 const factor = tierFactor(usage.model, uncached + hit + written)
360 s.premium += (factor - 1) * weightedTokens(request, usage.model)
361 s.tieredReads += factor * hit
362}
363
364/** Whether a main-loop request that read `hit` tokens, `gap` after the
365 * last, read short of a cache that should have been warm, by enough tokens
366 * and enough of what it held: within its TTL, on the same model, and with
367 * nothing rebuilding it on purpose. */
368const readShort = (s: CacheState, hit: number, gap: number, switched: boolean): boolean => {
369 const prefix = s.cached
370 if (prefix <= 0 || s.rebuilding || switched || gap > TTL_MS[s.ttl]) return false
371 const shortfall = prefix - hit
372 return shortfall >= MISS_MIN_TOKENS && shortfall > prefix * MISS_MIN_SHARE
373}
374
375/** A main-loop response, sent at `sentAt`, as a measure of the cache: a
376 * miss or a rebuild, what it leaves in the cache, and the model its reply
377 * was billed under. A subagent's prefix is its own, so it says nothing of
378 * the main conversation's cache or its countdown. */
379const measureCache = (s: CacheState, usage: Response, sentAt: number): void => {
380 const model = usage.model
381 const hit = usage.cache_read_input_tokens
382 const written = usage.cache_creation_input_tokens
383 if (model) s.billedModel = model
384
385 // Read as cold already: a resume the engine called expired, or a recalled
386 // reply past its TTL.
387 const coldBefore = isRecalled(s) && leftMsOf(s, sentAt) === 0
388 const gap = s.requests > 0 ? sentAt - s.lastAt : 0
389 // Another model has its own cache: reading nothing after a switch is no miss.
390 const switched = s.model !== undefined && model !== undefined && model !== s.model
391 const short = readShort(s, hit, gap, switched)
392 // An assumed hour that read nothing after five idle minutes was five.
393 const wasFive = short && !s.ttlPinned && s.ttl === '1h' && hit === 0 && gap > TTL_MS['5m']
394 if (wasFive) s.ttl = '5m'
395 const missed = short && !wasFive
396 if (missed) s.misses += 1
397
398 // A rebuild of a cache that existed: its time ran out, it missed, a
399 // compaction rebuilt it, or the model changed. The first build is warming,
400 // unless the cache it rebuilt was already read as cold.
401 if (coldBefore || (s.requests > 0 && (gap > TTL_MS[s.ttl] || missed || s.rebuilding || switched))) s.lastRebuilt = true
402
403 s.requests += 1
404 // The next request's TTL follows the config in force, not the transcript's.
405 s.ttlSeen = undefined
406 s.window = usage.input_tokens + hit + written + usage.output_tokens
407 s.cached = hit + written
408 s.lastAt = sentAt
409 s.rebuilding = false
410 s.compactedAt = undefined
411 s.model = model ?? s.model
412}
413
414// ---- the model --------------------------------------------------------------
415
416/** A response, as turn.step has it. */
417type Response = Pick<ModelUsage, 'input_tokens' | 'output_tokens' | 'cache_read_input_tokens' | 'cache_creation_input_tokens'> &
418 Readonly<{ model?: string }>
419
420/** What a session remembers of a turn, for when it is reopened or the band
421 * reloads: when its last reply was sent, the rate its bill solves to, the
422 * model that reply was billed under, and the model /model names. */
423export type Remembered = Readonly<{ lastAt: number; rate: number | null; billed: string | undefined; priceModel: string | undefined }>
424
425/** One session's cache model. Each entry is a hook's facts, in its shape,
426 * and each that is handed the ledger notes it before it reads it. */
427export type CacheModel = Readonly<{
428 /** session.start, after a resume of this session is noted: the model
429 * /model names, and the ledger. A ledger that has spent nothing is a new
430 * conversation, unless the engine said it resumed one; one that can't be
431 * read says neither, so the band recalls the last reply. */
432 loaded: (o: Readonly<{ priceModel: string | undefined; ledger: number | undefined }>) => void
433 /** Whether to recall the last reply: the engine said neither that the
434 * conversation starts here nor when its last reply was. */
435 wantsRecall: () => boolean
436 /** What the band recalls of the last reply, noted until this band's first
437 * reply, which speaks for itself; and what a resumed conversation's
438 * transcript shows, once read. */
439 recalled: (reply: RecalledReply | undefined, transcript?: TranscriptFacts) => void
440 /** session.start, once its settings are read: the TTL they pin, and where
441 * idle compaction runs from. */
442 configured: (o: Readonly<{ ttl: Ttl | undefined; idleCompactFrom: number | undefined }>) => void
443 /** classic.SessionStart, a resume or fork: the engine's word on its cache,
444 * and its transcript's once read. */
445 resumed: (o: Readonly<{ cache: ResumedCache | undefined; transcript: TranscriptFacts | undefined }>) => void
446 /** turn.start of a main-loop turn: the model /model names now, and the
447 * ledger, which the conversation's first turn takes as its baseline. */
448 turnStarted: (o: Readonly<{ priceModel: string | undefined; ledger: number | undefined }>) => void
449 /** turn.step: a response's tokens, sent at `sentAt`. */
450 responded: (usage: Response, sentAt: number, isMain: boolean) => void
451 /** The ledger, as a hook read it after a response. */
452 ledgerRead: (ledger: number | undefined) => void
453 /** A compaction of the main conversation at `now`, to `sizeAfter` tokens.
454 * With no turn under way, not /compact, and long after the last request,
455 * it is Claude Code compacting the conversation before its cache ran out
456 * (the trigger it names isn't matched, so a new one still counts); else
457 * the cache is rebuilt on purpose. */
458 compacted: (o: Readonly<{ now: number; sizeAfter?: number; manual?: boolean }>) => void
459 /** turn.complete of a main-loop turn: whether a request in it rebuilt the
460 * cache (TTL run out, a miss, a compaction, a model switch), taken once
461 * per turn, though the rebuild is usually its first request; and, once a
462 * reply has been seen, what to remember at `ledger`. */
463 turnEnded: (ledger: number | undefined) => Readonly<{ rebuilt: boolean; remember: Remembered | undefined }>
464 /** session.end: another conversation in the same process, at `ledger`. A
465 * /clear starts one here; a /resume moves to one already under way. */
466 conversationEnded: (ledger: number, resumed: boolean) => void
467 /** ui.render, with the ledger as it reads now: the cache's view, and what
468 * the session has cost, to show. */
469 view: (o: Readonly<{ now: number; ledger: number | undefined; contextTokens: number; autoCompactOff: boolean }>) => Readonly<{ cache: CacheView; costUsd: number }>
470 /** The cache's time left at `now`, for the timer's repaint. */
471 leftMs: (now: number) => number
472 /** How long a recalled cache has sat idle at `now`, for the timer's
473 * repaint; null unless it stands as recalled. */
474 idleMs: (now: number) => number | null
475}>
476
477/** A session's cache model, before anything is known of it. */
478export const newCache = (): CacheModel => {
479 const s: CacheState = { ...INITIAL }
480 return {
481 loaded: ({ priceModel, ledger }) => {
482 s.priceModel = priceModel
483 if (!s.resumed) s.knownFresh = ledger === 0
484 },
485 wantsRecall: () => !s.knownFresh && s.resumedCache === undefined,
486 recalled: (reply, transcript) => {
487 if (s.requests === 0) {
488 if (reply?.billed !== undefined) s.billedModel = reply.billed
489 if (reply?.lastAt !== undefined) s.recall = { lastAt: reply.lastAt, rate: reply.rate }
490 }
491 if (transcript !== undefined) noteTranscript(s, transcript)
492 },
493 configured: ({ ttl, idleCompactFrom }) => {
494 if (ttl !== undefined) {
495 s.ttl = ttl
496 s.ttlPinned = true
497 }
498 s.idleCompactFrom = idleCompactFrom
499 },
500 resumed: ({ cache, transcript }) => {
501 noteResume(s, cache)
502 if (transcript !== undefined) noteTranscript(s, transcript)
503 },
504 turnStarted: ({ priceModel, ledger }) => {
505 s.turnOpen = true
506 s.priceModel = priceModel
507 // The ledger as this conversation's first turn starts: its baseline.
508 if (ledger !== undefined && !s.baselined) {
509 s.costBase = ledger
510 s.baselined = true
511 }
512 },
513 responded: (usage, sentAt, isMain) => {
514 billResponse(s, usage)
515 if (isMain) measureCache(s, usage, sentAt)
516 },
517 ledgerRead: ledger => noteLedger(s, ledger),
518 compacted: ({ now, sizeAfter, manual = false }) => {
519 const idle = !s.turnOpen && !manual && s.requests > 0 && now - s.lastAt >= IDLE_GAP_MS
520 noteCompaction(s, sizeAfter)
521 // An idle one's cache counts as gone from then, at the summary's size.
522 if (idle && sizeAfter !== undefined) s.compactedAt = now
523 },
524 turnEnded: ledger => {
525 s.turnOpen = false
526 const rebuilt = s.lastRebuilt
527 s.lastRebuilt = false
528 return { rebuilt, remember: s.requests === 0 ? undefined : { lastAt: s.lastAt, rate: ratePerToken(s, ledger), billed: s.billedModel, priceModel: s.priceModel } }
529 },
530 conversationEnded: (ledger, resumed) => {
531 // The billing mode, its TTL and the session's settings carry over;
532 // everything measured starts again. The baseline is provisional until
533 // the next turn starts and takes the ledger then.
534 const { ttl, ttlPinned, priceModel, billedModel, idleCompactFrom, turnOpen } = s
535 Object.assign(s, INITIAL, { ttl, ttlPinned, priceModel, billedModel, idleCompactFrom, turnOpen, costBase: ledger, knownFresh: !resumed })
536 },
537 view: ({ now, ledger, contextTokens, autoCompactOff }) => {
538 noteLedger(s, ledger)
539 return { cache: viewOf(s, now, ledger, contextTokens, autoCompactOff), costUsd: spentUsd(s, ledger) }
540 },
541 leftMs: now => leftMsOf(s, now),
542 idleMs: now => idleMsOf(s, now),
543 }
544}
545
546// ---- what it means ----------------------------------------------------------
547
548/** Unmeasured: loaded mid-conversation, before its next reply. Compacted:
549 * cold because Claude Code compacted the conversation while it sat idle, so
550 * the next message writes the summary. */
551type CacheMood = 'unmeasured' | 'warming' | 'warm' | 'expiring' | 'cold' | 'compacted'
552
553/** The cache as the band reads it: its view, and what that means. */
554export type CacheFacts = CacheView &
555 Readonly<{
556 mood: CacheMood
557 tone: Tone
558 /** The battery's charge: the share of the lifetime left, full while
559 * Claude works, empty when there is nothing to count. */
560 charge: number
561 /** What the next message costs to rebuild the cache: dollars when a rate
562 * is known, else the tokens it writes. */
563 estimate: string
564 /** A reply seen, or a resumed conversation's tokens read off its transcript. */
565 measured: boolean
566 /** Measured, or recalled from the session's last reply: time and price known. */
567 known: boolean
568 /** No time to count yet, neither seen nor recalled: unmeasured, or warming. */
569 untimed: boolean
570 /** The hit ratio, once measured. */
571 hitFrac: number | undefined
572 /** Time to cold while it counts down: expiring, or warm while Claude isn't working. */
573 coldInMs: number | undefined
574 /** Departures' calm last call, LAST CALL at its clock: under LAST_CALL_MS
575 * to cold, or for the 5-minute cache its last minute. The board draws the
576 * last minute by its amber tone first, as every layout does. */
577 lastCall: boolean
578 /** Counting down on the hour's cache before the ~54-minute mark, where
579 * Claude Code may compact the conversation instead of letting it go cold. */
580 idleCompactSoon: boolean
581 }>
582
583const moodOf = (c: CacheView): CacheMood =>
584 c.requests === 0 && !c.recalled
585 ? c.fresh
586 ? 'warming'
587 : 'unmeasured'
588 : c.leftMs <= 0
589 ? c.compacted
590 ? 'compacted'
591 : 'cold'
592 : c.leftMs <= SOON_MS
593 ? 'expiring'
594 : 'warm'
595
596/** What the cache's view means while Claude is working, or isn't. */
597export const cacheFactsOf = (c: CacheView, isWorking: boolean): CacheFacts => {
598 const mood = moodOf(c)
599 const measured = c.requests > 0 || c.recovered
600 // Mid-turn every step restarts the TTL, so a countdown would only bounce.
601 const counting = mood === 'expiring' || (mood === 'warm' && !isWorking)
602 const untimed = mood === 'unmeasured' || mood === 'warming'
603 const timeless = untimed || mood === 'cold' || mood === 'compacted'
604 return {
605 ...c,
606 mood,
607 tone: mood === 'expiring' ? 'amber' : 'calm',
608 charge: timeless ? 0 : mood === 'warm' && isWorking ? 1 : c.leftMs / TTL_MS[c.ttl],
609 estimate: c.reWarmUsd !== null ? fmtEstimate(c.reWarmUsd) : `${fmtTokens(c.window)} tokens`,
610 measured,
611 known: measured || c.recalled,
612 untimed,
613 hitFrac: measured && c.hitRatio !== null ? c.hitRatio : undefined,
614 coldInMs: counting ? c.leftMs : undefined,
615 // A 5-minute cache is always under ten minutes from cold, so its last
616 // call is its last minute, when chips turns amber too.
617 lastCall: counting && (c.ttl === '5m' ? mood === 'expiring' : c.leftMs < LAST_CALL_MS),
618 idleCompactSoon: counting && c.idleCompacts && c.ttl === '1h' && c.leftMs > TTL_MS['1h'] - IDLE_COMPACT_MS,
619 }
620}
621hooks/cache-settings.ts 47 lines1// What the session's environment and settings say of its cache: the TTL
2// they pin, if any, and where Claude Code compacts the conversation when it
3// sits idle. Pure: register.tsx reads each variable and setting by name and
4// hands them here.
5
6import type { Ttl } from './cache'
7
8const IDLE_COMPACT_TOKENS = 200_000
9const IDLE_COMPACT_FLOOR = 100_000
10
11const truthy = (v: string | undefined): boolean => v !== undefined && ['1', 'true', 'yes', 'on'].includes(v.trim().toLowerCase())
12
13/** The context, in tokens, from which Claude Code compacts this session when
14 * it sits idle; undefined when it never does: outside an interactive
15 * terminal, `idleCompaction: false`, or compaction turned off. The threshold
16 * takes a positive whole number, raised to 100k, as Claude Code does. */
17export const idleCompactFrom = (o: {
18 terminal: boolean
19 setting: unknown
20 disableAuto: string | undefined
21 disableAll: string | undefined
22 minTokens: string | undefined
23}): number | undefined => {
24 if (!o.terminal || o.setting === false || truthy(o.disableAuto) || truthy(o.disableAll)) return undefined
25 const min = o.minTokens !== undefined && /^\d+$/.test(o.minTokens.trim()) ? Number(o.minTokens.trim()) : 0
26 return min > 0 ? Math.max(IDLE_COMPACT_FLOOR, min) : IDLE_COMPACT_TOKENS
27}
28
29const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
30
31/** The TTL the environment or the settings pin, if they pin one, in Claude
32 * Code's order: forcing 5m, the main conversation's variable, then its
33 * `promptCacheTtl` setting, then asking for 1h. Any other value is ignored,
34 * as Claude Code ignores it. */
35export const resolveTtl = (env: {
36 force5m: string | undefined
37 chosen: string | undefined
38 setting: unknown
39 enable1h: string | undefined
40}): Ttl | undefined => {
41 if (env.force5m === '1') return '5m'
42 const chosen = asTtl(env.chosen) ?? asTtl(env.setting)
43 if (chosen !== undefined) return chosen
44 if (env.enable1h === '1') return '1h'
45 return undefined
46}
47hooks/command.ts 104 lines1// What /nextcost answers and changes, and how it reads its arguments. Pure:
2// a command and the band's state make a plan, which register.tsx carries out
3// against the engine and the store.
4
5import { clipMiddle } from './layout'
6import { DEFAULT_LAYOUT, LAYOUT_NAMES, REMOVED_IN, asLayoutName, removedLayoutOf } from './snapshot'
7import type { LayoutName } from './snapshot'
8
9/** The words /nextcost takes on their own. */
10const WORDS = ['more', 'less', 'show', 'hide'] as const
11type Word = (typeof WORDS)[number]
12const WORD_CHOICES = WORDS.join(' | ')
13
14/** What /nextcost takes, as the menu hints it after the command's name. */
15export const ARGUMENT_HINT = `[${WORD_CHOICES} | layout <name>]`
16
17/** What /nextcost answers. */
18export const REPLY = {
19 shown: 'nextcost shown.',
20 shownFirst: 'nextcost shown. /nextcost more shows every fact.',
21 hidden: 'nextcost hidden. /nextcost shows it again.',
22 expanded: 'nextcost expanded.',
23 collapsed: 'nextcost collapsed.',
24 usage: `Usage: /nextcost [${WORD_CHOICES}] · /nextcost layout <name>`,
25} as const
26
27/** What /nextcost was asked: a bare word, or `layout` and what follows it. */
28type BandCommand = 'toggle' | Word | Readonly<{ layout: string }>
29
30const isWord = (word: string): word is Word => (WORDS as readonly string[]).includes(word)
31
32/** `layout`, then what follows it, if anything. */
33const LAYOUT_ARG = /^\s*layout(?:\s+(.*))?$/i
34
35/** The command in `args`; undefined when it is none. */
36export const parseCommand = (args: string): BandCommand | undefined => {
37 const chosen = LAYOUT_ARG.exec(args)
38 if (chosen) return { layout: chosen[1] ?? '' }
39 const word = args.trim().toLowerCase()
40 if (word === '') return 'toggle'
41 return isWord(word) ? word : undefined
42}
43
44/** The layouts /nextcost layout offers. */
45const LAYOUT_PURPOSE: Readonly<Record<LayoutName, string>> = { chips: 'at a glance', pulse: 'trends', departures: 'times' }
46const LAYOUT_LIST = `Choose one: ${LAYOUT_NAMES.map(n => `${n} (${LAYOUT_PURPOSE[n]})`).join(', ')}.`
47
48/** The most of an unknown layout's name a reply echoes, in columns. */
49const ECHOED_NAME = 20
50
51/** What a layout reply adds when the store couldn't keep it. */
52const UNSAVED = "It couldn't be saved, so it lasts until Claude's next reply."
53/** That a layout is gone, and since when. */
54const removedNote = (name: string): string => `${name} was removed in ${REMOVED_IN}`
55/** The reply naming the layout the band draws in. */
56const layoutReply = (name: LayoutName, removed?: string): string =>
57 `nextcost layout: ${name}${removed === undefined ? '' : ` (${removedNote(removed)})`}.`
58
59/** What the band holds as /nextcost runs: the layout drawn, a removed one
60 * the store still names, and whether the band is hidden. */
61export type CommandState = Readonly<{ layout: LayoutName; removed: string | undefined; hidden: boolean }>
62
63/** What /nextcost does: its reply, the layout it switches to and saves, with
64 * the reply should the save fail, the atoms' new values, and whether it
65 * reads the workspace. Undefined is unchanged. */
66type CommandPlan = Readonly<{
67 reply: string
68 layout?: Readonly<{ name: LayoutName; unsaved: string }>
69 expanded?: boolean
70 hidden?: boolean
71 readWorkspace: boolean
72}>
73
74/** `/nextcost layout [name]`: lists, or switches and remembers. Only this
75 * plan writes the layout, and it shows the band. */
76const layoutPlan = (arg: string, state: CommandState): CommandPlan => {
77 if (arg.trim() === '') return { reply: `${layoutReply(state.layout, state.removed)} ${LAYOUT_LIST}`, readWorkspace: false }
78 const name = asLayoutName(arg)
79 const gone = removedLayoutOf(arg)
80 if (gone !== undefined) return { reply: `${removedNote(gone)}. ${LAYOUT_LIST}`, readWorkspace: false }
81 if (name === undefined) return { reply: `Unknown layout "${clipMiddle(arg.trim(), ECHOED_NAME)}". ${LAYOUT_LIST}`, readWorkspace: false }
82 const reply = name === DEFAULT_LAYOUT ? layoutReply(name) : `${layoutReply(name)} /nextcost layout ${DEFAULT_LAYOUT} goes back.`
83 return { reply, layout: { name, unsaved: `${reply} ${UNSAVED}` }, hidden: false, readWorkspace: false }
84}
85
86/** What /nextcost, or its old name /usage-band, does for `command`, given `state`. */
87export const commandPlan = (command: BandCommand | undefined, state: CommandState): CommandPlan => {
88 if (typeof command === 'object') return layoutPlan(command.layout, state)
89 switch (command) {
90 case 'more':
91 return { reply: REPLY.expanded, expanded: true, hidden: false, readWorkspace: true }
92 case 'less':
93 return { reply: REPLY.collapsed, expanded: false, hidden: false, readWorkspace: false }
94 case 'show':
95 return { reply: REPLY.shown, hidden: false, readWorkspace: false }
96 case 'hide':
97 return { reply: REPLY.hidden, hidden: true, readWorkspace: false }
98 case 'toggle':
99 return { reply: state.hidden ? REPLY.shownFirst : REPLY.hidden, hidden: !state.hidden, readWorkspace: false }
100 case undefined:
101 return { reply: REPLY.usage, readWorkspace: false }
102 }
103}
104hooks/format.ts 287 lines1// Numbers, times and escalation marks, as the band words them, and the
2// paint key: every phrase the clock alone moves, as one key.
3
4export const MINUTE_MS = 60_000
5/** The cache's last minute: the one span where acting changes the bill. */
6export const SOON_MS = MINUTE_MS
7/** Under ten minutes a countdown is close: see `grainOf`. */
8const CLOSE_MS = 10 * MINUTE_MS
9
10/** How finely a countdown with `ms` left is said: `seconds` in its last
11 * minute, where every form counts seconds; `close` under ten minutes, where
12 * chips' `M:SS` still does and the other forms count whole minutes rounded
13 * up; else `minutes`, whole and rounded down. */
14export const grainOf = (ms: number): 'seconds' | 'close' | 'minutes' => (ms < MINUTE_MS ? 'seconds' : ms < CLOSE_MS ? 'close' : 'minutes')
15
16/** Departures' calm last call: an hour's cache this near cold says LAST CALL at
17 * its clock, in the board's calm ink, from where its countdown is close.
18 * Only the last minute is amber. */
19export const LAST_CALL_MS = CLOSE_MS
20
21/** At this share a pill turns amber and gains `!`, and a toast speaks. */
22export const WARN_AT = 0.8
23/** At this share the mark becomes `!!`, and a toast speaks again. */
24export const SEVERE_AT = 0.95
25/** Within this share of the compaction point, context counts down to it. */
26const COMPACT_NEAR = 0.9
27
28/** `part` as a share of `whole`; a share of nothing is none. */
29const shareOf = (part: number, whole: number): number => (whole > 0 ? part / whole : 0)
30
31/** How full the context is against where it runs out, and the levels it
32 * escalates at there: toward compaction, where it is on and its point
33 * known, near it; else toward the window, at the warning and severe lines.
34 * Unclamped: a reading clamps it, a toast escalates on it as it is. */
35export const contextPressure = (used: number, window: number, compactAt: number | undefined): Readonly<{ frac: number; levels: readonly number[] }> =>
36 compactAt === undefined ? { frac: shareOf(used, window), levels: [WARN_AT, SEVERE_AT] } : { frac: shareOf(used, compactAt), levels: [COMPACT_NEAR] }
37
38/** The tokens left before auto-compaction runs at `compactAt`; none once past it. */
39export const toCompaction = (used: number, compactAt: number): number => Math.max(0, compactAt - used)
40
41/** `n` held to 0–1; NaN, a share of nothing, is 0. */
42export const clamp01 = (n: number): number => (Number.isNaN(n) || n < 0 ? 0 : n > 1 ? 1 : n)
43
44/** A percent as a whole one: 82.4 is `82%`. */
45export const fmtPercent = (percent: number): string => `${Math.round(percent)}%`
46/** A share as a whole percent: 0.38 is `38%`. */
47export const fmtPct = (frac: number): string => fmtPercent(frac * 100)
48
49export const fmtTokens = (n: number): string => {
50 const v = Math.max(0, Math.round(n))
51 // Each unit from where the one below would round up to it: 9,999 is 10k,
52 // not 10.0k; 999,999 is 1.0M, not 1000k; 999.95M is 1.0B, not 1000.0M.
53 if (v >= 999_950_000) return `${(v / 1_000_000_000).toFixed(1)}B`
54 if (v >= 999_500) return `${(v / 1_000_000).toFixed(1)}M`
55 if (v >= 9_950) return `${Math.round(v / 1000)}k`
56 if (v >= 1_000) return `${(v / 1000).toFixed(1)}k`
57 return String(v)
58}
59
60/** Whole dollars from $1,000, and from what would round to it. */
61const WHOLE_FROM = 999.995
62
63/** Whole dollars grouped by thousands, `1,027`: by hand, since the sandbox's
64 * locale formatting can't be relied on. */
65const wholeDollars = (usd: number): string => String(Math.round(usd)).replace(/\B(?=(\d{3})+$)/g, ',')
66
67export const fmtCost = (usd: number): string => (usd >= WHOLE_FROM ? `$${wholeDollars(usd)}` : `$${usd.toFixed(2)}`)
68
69/** A small figure honestly: under a cent is not $0.00. */
70export const fmtSmallCost = (usd: number): string => (usd < 0.01 ? '<$0.01' : fmtCost(usd))
71
72/** An estimate is always marked as one. */
73export const fmtEstimate = (usd: number): string => `~${fmtSmallCost(usd)}`
74
75/** The countdowns' seconds, drawn and spoken alike. */
76const wholeSeconds = (ms: number): number => Math.max(0, Math.round(ms / 1000))
77
78/** From an hour, `1h 05m`; from ten minutes, whole minutes; below, `M:SS`.
79 * The countdown is still for most of its life and ticks only when ticking
80 * means something. */
81export const fmtCountdown = (ms: number): string => {
82 const secs = wholeSeconds(ms)
83 if (secs >= 3600) {
84 const hours = Math.floor(secs / 3600)
85 return `${hours}h ${String(Math.floor((secs % 3600) / 60)).padStart(2, '0')}m`
86 }
87 if (grainOf(secs * 1000) === 'minutes') return `${Math.floor(secs / 60)}m`
88 return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
89}
90
91/** How far off a reset is: the time left, or that it has already passed. */
92export type ResetIn = { kind: 'in'; text: string } | { kind: 'passed' }
93
94/** A span coarsely, to the minute: `2d 4h`, `3h 05m`, `12m`, `<1m`. */
95const fmtSpan = (ms: number): string => {
96 const mins = Math.floor(Math.max(0, ms) / MINUTE_MS)
97 const hours = Math.floor(mins / 60)
98 const days = Math.floor(hours / 24)
99 if (days) return `${days}d ${hours % 24}h`
100 if (hours) return `${hours}h ${String(mins % 60).padStart(2, '0')}m`
101 return mins > 0 ? `${mins}m` : '<1m'
102}
103
104/** The time to a reset `ms` off: coarsely, and while close in whole minutes
105 * rounded up, as every close countdown counts. */
106const fmtResetSpan = (ms: number): string => (grainOf(ms) === 'close' ? `${minutesLeft(ms)}m` : fmtSpan(ms))
107
108/** When a reset the engine names in ISO time comes, epoch ms; undefined
109 * without a readable time. */
110export const parseReset = (iso: string | undefined): number | undefined => {
111 if (!iso) return undefined
112 const at = Date.parse(iso)
113 return Number.isNaN(at) ? undefined : at
114}
115
116/** When a reset at `at` comes, from `now`; undefined without one. */
117export const resetIn = (at: number | undefined, now: number): ResetIn | undefined => {
118 if (at === undefined) return undefined
119 const left = at - now
120 return left <= 0 ? { kind: 'passed' } : { kind: 'in', text: fmtResetSpan(left) }
121}
122
123/** The words for escalation: colour is never the only signal. */
124export const severityMark = (frac: number): string => (frac >= SEVERE_AT ? '!!' : frac >= WARN_AT ? '!' : '')
125
126/** A projection's whole minutes: 5-minute steps under an hour, 15 from one. */
127export const etaMinutes = (ms: number): number => {
128 const mins = Math.max(0, ms) / MINUTE_MS
129 return mins < 57.5 ? Math.max(5, Math.round(mins / 5) * 5) : Math.round(mins / 15) * 15
130}
131
132/** A projection, never a countdown: `~40m`, `~1h`, `~1h 15m`. */
133export const fmtEta = (ms: number): string => {
134 const mins = etaMinutes(ms)
135 const hours = Math.floor(mins / 60)
136 const rest = mins % 60
137 return hours === 0 ? `~${mins}m` : rest ? `~${hours}h ${rest}m` : `~${hours}h`
138}
139
140/** How long ago, coarsely: `2d 4h`, `3h 05m`, `12m`; under a minute is `now`. */
141export const fmtAgo = (ms: number): string => (ms < MINUTE_MS ? 'now' : fmtSpan(ms))
142
143export const HOUR_MS = 60 * MINUTE_MS
144export const DAY_MS = 24 * HOUR_MS
145/** The 5-hour limit's window. */
146export const FIVE_HOUR_MS = 5 * HOUR_MS
147/** The weekly limit's window. */
148export const SEVEN_DAY_MS = 7 * DAY_MS
149/** Resets read of one window can differ by a few seconds. */
150const SAME_RESET_MS = MINUTE_MS
151/** Whether two resets name one window: both unknown, or within a minute. */
152export const sameReset = (a: number | undefined, b: number | undefined): boolean =>
153 a === b || (a !== undefined && b !== undefined && Math.abs(a - b) < SAME_RESET_MS)
154
155export const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
156
157/** A zone: its offset from UTC at an instant, east-positive minutes. Read
158 * per instant, so a reset past a daylight-saving change says its own clock. */
159export type Zone = (ms: number) => number
160/** A zone from a clock's minutes behind UTC at each instant, as
161 * getTimezoneOffset reports them (`0 -` keeps a UTC offset +0). */
162export const zoneOf =
163 (behind: (ms: number) => number): Zone =>
164 ms =>
165 0 - behind(ms)
166/** This machine's zone, as its clock reports each instant. */
167export const hostZone: Zone = zoneOf(ms => new Date(ms).getTimezoneOffset())
168/** `zone`, or undefined where its offset at `now` can't be read, so the band
169 * counts down instead. */
170export const readableZone = (zone: Zone, now: number): Zone | undefined => (Number.isFinite(zone(now)) ? zone : undefined)
171/** A zone whose offset never changes. */
172export const fixedZone =
173 (min: number): Zone =>
174 () =>
175 min
176
177/** `ms` as local wall time, to be read through the UTC getters. */
178const wallTime = (ms: number, zone: Zone): number => ms + zone(ms) * MINUTE_MS
179const hhmm = (wall: number): string => {
180 const at = new Date(wall)
181 return `${String(at.getUTCHours()).padStart(2, '0')}:${String(at.getUTCMinutes()).padStart(2, '0')}`
182}
183
184/** A local 24-hour clock time: `14:32`. */
185export const fmtClock = (ms: number, zone: Zone): string => hhmm(wallTime(ms, zone))
186
187/** The local day `ms` falls on, counted from the epoch. */
188export const dayOf = (ms: number, zone: Zone): number => Math.floor(wallTime(ms, zone) / DAY_MS)
189
190/** The first instant of local day `day`: the earliest of three passes that
191 * lands on the day, at the offset in force there and an hour before. Where
192 * midnight falls in a spring-forward gap, one lands in the day before, and
193 * the day starts at the gap's end; where it comes twice, at a fall back to
194 * 00:00, the hour before finds the first. */
195export const midnightOf = (day: number, zone: Zone): number => {
196 const at = (offsetAt: number): number => day * DAY_MS - zone(offsetAt) * MINUTE_MS
197 const first = at(day * DAY_MS)
198 const landed = [first, at(first), at(first - HOUR_MS)].filter(t => dayOf(t, zone) === day)
199 return landed.length === 0 ? first : Math.min(...landed)
200}
201
202/** Whether `ms` falls on the same local day as `now`, each under its own offset. */
203const isToday = (ms: number, zone: Zone, now: number): boolean => dayOf(ms, zone) === dayOf(now, zone)
204
205/** The weekday `ms` falls on, `Sat`; a week or more on `next Sat`, so it
206 * never reads as today's. */
207const weekdayOf = (ms: number, zone: Zone, now: number): string => {
208 const day = WEEKDAYS[new Date(wallTime(ms, zone)).getUTCDay()]?.slice(0, 3) ?? ''
209 return dayOf(ms, zone) - dayOf(now, zone) >= 7 ? `next ${day}` : day
210}
211
212/** A clock time, with its weekday when it isn't today: `16:40`, `Mon 08:40`, `next Sat 13:30`. */
213export const fmtDayClock = (ms: number, zone: Zone, now: number): string =>
214 isToday(ms, zone, now) ? fmtClock(ms, zone) : `${weekdayOf(ms, zone, now)} ${fmtClock(ms, zone)}`
215
216/** A projection's day, which its minute would claim too much for: its
217 * weekday, `Mon`, a week on `next Sun`, or today its clock time, `16:40`. */
218export const fmtDay = (ms: number, zone: Zone, now: number): string => (isToday(ms, zone, now) ? fmtClock(ms, zone) : weekdayOf(ms, zone, now))
219
220/** Whole seconds left, never 0 while any remain. */
221const secondsLeft = (ms: number): number => Math.max(1, Math.ceil(ms / 1000))
222/** Whole minutes left: rounded up while close, then as `fmtCountdown`
223 * counts them. */
224const minutesLeft = (ms: number): number => (grainOf(ms) === 'minutes' ? Math.floor(wholeSeconds(ms) / 60) : Math.ceil(ms / MINUTE_MS))
225
226/** The countdown alone, for a tile or a pill: `1h 00m`, `52m`, `10m`, `47s`. */
227export const fmtLeftShort = (ms: number): string => {
228 const grain = grainOf(ms)
229 return grain === 'seconds' ? `${secondsLeft(ms)}s` : grain === 'close' ? `${minutesLeft(ms)}m` : fmtCountdown(ms)
230}
231
232/** A countdown in words: `1h 00m left`, `52m left`, `10m left`, `47s left`. */
233export const fmtLeft = (ms: number): string => `${fmtLeftShort(ms)} left`
234
235/** A measured span on the departures board: `47S`, `38 MIN`, `1H 04M`.
236 * Never `38M`. */
237export const boardSpan = (ms: number): string =>
238 grainOf(ms) === 'seconds' ? `${secondsLeft(ms)}S` : ms < HOUR_MS ? `${minutesLeft(ms)} MIN` : fmtCountdown(ms)
239
240export const plural = (n: number, unit: string): string => `${n} ${unit}${n === 1 ? '' : 's'}`
241
242/** Whole minutes spelled out: `40 minutes`, `1 hour`, `1 hour 5 minutes`. */
243const spokenMinutes = (mins: number): string => {
244 const hours = Math.floor(mins / 60)
245 const rest = mins % 60
246 return [hours > 0 ? plural(hours, 'hour') : '', rest > 0 || hours === 0 ? plural(rest, 'minute') : ''].filter(Boolean).join(' ')
247}
248
249/** The same countdown as a screen reader should hear it: `1 hour 5 minutes left`. */
250export const fmtLeftSpoken = (ms: number): string =>
251 `${grainOf(ms) === 'seconds' ? plural(secondsLeft(ms), 'second') : spokenMinutes(minutesLeft(ms))} left`
252
253/** A projection spelled out, rounded as `fmtEta` rounds it: `about 40 minutes`. */
254export const fmtEtaSpoken = (ms: number): string => `about ${spokenMinutes(etaMinutes(ms))}`
255
256/** The context in use: its token count, else its percent of the window;
257 * undefined when the engine reports neither. */
258export const contextUsed = (ctx: Readonly<{ tokens?: number; percent?: number; window: number }>): number | undefined =>
259 ctx.tokens ?? (ctx.percent === undefined ? undefined : (ctx.percent / 100) * ctx.window)
260
261/** A reset's countdown as every layout says it: the span a card and, with
262 * no zone known, a limit's words say, and the board's, which with no zone
263 * counts its last minute's seconds; with a zone the board says a clock. */
264const resetSpans = (left: number, zone: Zone | undefined): string =>
265 left <= 0 ? 'passed' : zone === undefined ? `${fmtResetSpan(left)} ${boardSpan(left)}` : fmtResetSpan(left)
266
267/** Every phrase the clock alone moves, as one key, so the 1-second tick
268 * repaints only when one would read differently: the minute (the clocks,
269 * the battery and the average's pace), the cache's countdown, how long a
270 * recalled cache has sat idle (null when none is drawn), which turns on its
271 * last reply's minute, the 5-hour fill's projection, and each reset's
272 * countdown, which turns on the reset's own minute. The minute turns just
273 * past the boundary, as the countdowns to a time on the minute do: on it,
274 * they still read the minute before. */
275export const paintKey = (
276 now: number,
277 t: Readonly<{ cacheLeft: number; idleMs: number | null; etaMs: number | null; resets: readonly number[]; zone: Zone | undefined }>,
278): string =>
279 [
280 Math.ceil(now / MINUTE_MS),
281 fmtCountdown(t.cacheLeft),
282 t.cacheLeft > 0,
283 t.idleMs === null ? '-' : fmtAgo(t.idleMs),
284 t.etaMs === null ? '-' : fmtEta(t.etaMs),
285 ...t.resets.map(at => resetSpans(at - now, t.zone)),
286 ].join('|')
287hooks/glyphs.ts 75 lines1// The terminal's glyph tier. East-Asian-Ambiguous glyphs (█ ▓ ▒ ▌ │ · Σ … ±)
2// draw two columns wide in a CJK locale, or with a terminal's "ambiguous is
3// wide" option, while Ink counts one, so the row would overflow. The ascii
4// tier maps them to ASCII; every mapping is one character at most, so the
5// squeeze, measured before it, never undercounts. A wide character, such as a
6// Japanese project's name, stays: every tier measures it two columns. Braille
7// is Neutral width and stays in the unicode tier. Unicode
8// EastAsianWidth-18.0.0, checked 2026-10-09.
9
10import { isWide } from './layout'
11import type { BandSnapshot, Glyphs } from './snapshot'
12
13const CJK = /^(ja|zh|ko)([_.-]|$)/i
14
15/** The tier a session draws in: as asked by NEXTCOST_GLYPHS, else ascii in a
16 * CJK locale (the first of LC_ALL, LC_CTYPE and LANG that is set wins). */
17export const resolveGlyphs = (env: Readonly<{ NEXTCOST_GLYPHS?: string; LC_ALL?: string; LC_CTYPE?: string; LANG?: string }>): Glyphs => {
18 const asked = env.NEXTCOST_GLYPHS?.toLowerCase()
19 if (asked === 'ascii' || asked === 'unicode') return asked
20 const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || ''
21 return CJK.test(locale) ? 'ascii' : 'unicode'
22}
23
24/** The tier the band draws in: the session's on a terminal, where
25 * ambiguous-width glyphs can draw wide; its own glyphs on every other surface. */
26export const drawnTier = (snap: Pick<BandSnapshot, 'surface' | 'glyphs'>): Glyphs => (snap.surface === 'terminal' ? snap.glyphs : 'unicode')
27
28/** Each glyph the band draws, in ASCII; an empty mapping drops the glyph. */
29export const ASCII_MAP: Readonly<Record<string, string>> = {
30 '█': '#', '░': '-', '▒': ':', '│': '|', '·': '-', '…': '.', '±': '+',
31 '●': '*', '■': '#', '–': '-', '↑': '+', '↓': '-', '▿': 'v', '▵': '^', '›': '>',
32 // A projection's dash and a battery's cap: Neutral, but not ASCII.
33 '╌': '~', '╸': '>',
34 '↻': '', 'Σ': '', '◷': '', '◔': '',
35 // A hover card's caps: a space each keeps its width. ▐ is Neutral, but
36 // goes with ▌ so a card's two ends match.
37 '▐': ' ', '▌': ' ',
38}
39
40/** `s` in ASCII and wide characters: a spaced separator a comma, since ` - `
41 * between numbers reads as a minus, and a screen reader says so; then each
42 * glyph mapped, a wide character kept, and any other non-ASCII character
43 * dropped alone. A glyph mapped to nothing takes the one space after it, so
44 * no gap is left where it was. */
45export const asciiText = (s: string): string => {
46 const chars = [...s.replace(/ · /g, ', ')]
47 let out = ''
48 for (let i = 0; i < chars.length; i++) {
49 const ch = chars[i] ?? ''
50 const mapped = ASCII_MAP[ch]
51 if (mapped === '' && chars[i + 1] === ' ') i++
52 out += mapped ?? (ch.charCodeAt(0) < 0x80 || isWide(ch) ? ch : '')
53 }
54 return out
55}
56
57/** The tree with every string, and every Button label, in ASCII. A node
58 * gains no field it didn't have. */
59export const asciiTree = <T>(tree: T): T => {
60 const walk = (k: unknown): unknown => {
61 if (typeof k === 'string') return asciiText(k)
62 if (Array.isArray(k)) return k.map(walk)
63 if (k === null || typeof k !== 'object') return k
64 const props = 'props' in k && typeof k.props === 'object' && k.props !== null ? k.props : undefined
65 const label = props !== undefined && 'label' in props ? props.label : undefined
66 return {
67 ...k,
68 ...(typeof label === 'string' ? { props: { ...props, label: asciiText(label) } } : {}),
69 ...('children' in k && Array.isArray(k.children) ? { children: k.children.map(walk) } : {}),
70 }
71 }
72 // The walk keeps every node's shape, which its type can't follow.
73 return walk(tree) as T
74}
75hooks/insights.ts 122 lines1// What the band can tell you that the engine's own figures don't: what your
2// last message cost, when your pace fills the 5-hour limit, and the trails
3// pulse draws: each message's cost, and the context. One set per session,
4// made at session.start beside the cache model.
5
6import { MINUTE_MS, sameReset } from './format'
7
8/** The pace is measured over the last half hour's points: across ten
9 * minutes at least, and a rise of 2 points, the newest no staler than a
10 * quarter of an hour. */
11const LOOKBACK_MS = 30 * MINUTE_MS
12const MIN_SPAN_MS = 10 * MINUTE_MS
13const MIN_RISE = 2
14const STALE_MS = 15 * MINUTE_MS
15
16/** The newest messages' costs the cost trail keeps. */
17export const COST_TRAIL = 24
18/** The newest turns' context the context trail keeps. */
19export const CONTEXT_TRAIL = 40
20
21/** What a main-loop message cost, and whether its turn rebuilt the cache. */
22export type CostEntry = Readonly<{ usd: number; reWarm: boolean }>
23/** The 5-hour window's use at one moment. */
24type PacePoint = Readonly<{ at: number; pct: number }>
25export type Trails = Readonly<{ costs: readonly CostEntry[]; context: readonly number[] }>
26
27/** The mean cost of the messages that found the cache warm: a re-warm's
28 * rebuild left out. Undefined while there is none. */
29export const warmAverageOf = (costs: readonly CostEntry[]): number | undefined => {
30 const warm = costs.filter(e => !e.reWarm)
31 return warm.length === 0 ? undefined : warm.reduce((sum, e) => sum + e.usd, 0) / warm.length
32}
33
34/** What the last message cost: the trail's newest cost, the one fact every
35 * layout reads; null before the conversation's first. A turn that cost
36 * nothing is never on the trail, so the last that did still stands. */
37export const lastCostOf = (t: Trails): number | null => t.costs.at(-1)?.usd ?? null
38
39type InsightState = {
40 /** The ledger as each open main-loop turn started, by turn id. */
41 turnStartCost: Map<string, number>
42 /** The 5-hour window's use as measured, oldest first, within the lookback. */
43 points: readonly PacePoint[]
44 /** When the 5-hour window resets, epoch ms. */
45 resetsAt: number | undefined
46 /** Each trail, replaced on every push and never changed in place, so a
47 * snapshot that holds one holds it as it stood. */
48 costs: readonly CostEntry[]
49 context: readonly number[]
50}
51
52/** One session's insights. Each entry is a hook's facts, in its shape. */
53export type Insights = Readonly<{
54 /** turn.start of a main-loop turn: the ledger as it starts. */
55 noteTurnStart: (turnId: string, costUsd: number | undefined) => void
56 /** turn.complete of a main-loop turn: everything the ledger rose by during
57 * it, its tool calls and subagents and any background work that ran
58 * meanwhile; null unless known and above zero. */
59 noteTurnEnd: (turnId: string, costUsd: number | undefined) => number | null
60 /** What a main-loop message cost, and whether its turn rebuilt the cache. */
61 pushCost: (usd: number, reWarm: boolean) => void
62 /** The context's tokens at a turn's end. */
63 pushContext: (tokens: number) => void
64 /** session.measure: the 5-hour window's use at `now`, and its reset. */
65 noteFiveHour: (now: number, percentUsed: number, resetsAt: number | undefined) => void
66 /** Time until your pace fills the 5-hour window, or null while the
67 * evidence is thin, stale, or the window resets first. */
68 fiveHourEtaMs: (now: number) => number | null
69 /** session.end: a new conversation (/clear, resume). Its turns and their
70 * trails start over, but the 5-hour window is account-wide, so its pace
71 * carries on. */
72 conversationEnded: () => void
73 /** The trails as they stand, for a snapshot to hold. */
74 trails: () => Trails
75}>
76
77/** A session's insights, before anything is known of it. */
78export const newInsights = (): Insights => {
79 const s: InsightState = { turnStartCost: new Map(), points: [], resetsAt: undefined, costs: [], context: [] }
80 return {
81 noteTurnStart: (turnId, costUsd) => {
82 if (costUsd !== undefined) s.turnStartCost.set(turnId, costUsd)
83 },
84 noteTurnEnd: (turnId, costUsd) => {
85 const start = s.turnStartCost.get(turnId)
86 s.turnStartCost.delete(turnId)
87 if (start === undefined || costUsd === undefined) return null
88 const spent = costUsd - start
89 return spent > 0 ? spent : null
90 },
91 pushCost: (usd, reWarm) => {
92 s.costs = [...s.costs, { usd, reWarm }].slice(-COST_TRAIL)
93 },
94 pushContext: tokens => {
95 s.context = [...s.context, tokens].slice(-CONTEXT_TRAIL)
96 },
97 noteFiveHour: (now, percentUsed, resetsAt) => {
98 const last = s.points.at(-1)
99 const kept = !sameReset(resetsAt, s.resetsAt) || (last !== undefined && percentUsed < last.pct) ? [] : s.points
100 s.resetsAt = resetsAt
101 s.points = [...kept, { at: now, pct: percentUsed }].filter(p => now - p.at <= LOOKBACK_MS)
102 },
103 fiveHourEtaMs: now => {
104 const first = s.points[0]
105 const last = s.points.at(-1)
106 if (first === undefined || last === undefined || last.pct >= 100) return null
107 const span = last.at - first.at
108 const rise = last.pct - first.pct
109 if (span < MIN_SPAN_MS || rise < MIN_RISE || now - last.at > STALE_MS) return null
110 const tFull = last.at + ((100 - last.pct) * span) / rise
111 if (s.resetsAt !== undefined && tFull >= s.resetsAt) return null
112 return Math.max(0, tFull - now)
113 },
114 conversationEnded: () => {
115 s.turnStartCost.clear()
116 s.costs = []
117 s.context = []
118 },
119 trails: () => ({ costs: s.costs, context: s.context }),
120 }
121}
122hooks/palette.ts 220 lines1// The band's palettes, dark, light and plain, and the colours for what sits
2// on its bare ground.
3
4/** A filled pill needs its foreground and background from one source: a
5 * theme key resolves against the user's theme, a hex does not, and mixing
6 * them makes a pill that is legible on one theme and blank on the other.
7 * Nothing in the API reports whether the theme is light or dark, so the
8 * palette is declared rather than guessed: NEXTCOST_APPEARANCE = dark |
9 * light | plain. */
10export type Palette = {
11 filled: boolean
12 surface: string
13 value: string
14 label: string
15 /** A warm cache: the battery's charge, and the dot in plain appearance. */
16 warm: string
17 /** A cold cache's dot in plain appearance. */
18 cold: string
19 amberBg: string
20 amberFg: string
21 /** The desktop's ground inside a meter's, a track's and a guessed day's
22 * edge, which trackStroke draws. */
23 meterTrack: string
24 /** A text track's empty cells (░ ⣀) and an unmeasured day's edge: 3:1
25 * against every ground they sit on. */
26 trackInk: string
27 meterFill: string
28 /** The expanded view's cards: fill, edge, and their values. */
29 cardBg: string
30 cardBorder: string
31 cardValue: string
32 /** A hover card's ground and ink, inverted against the palette (light in
33 * dark, dark in light), so a card over the row can't pass for one more
34 * chip: no chip uses that ground. */
35 tooltipBg: string
36 tooltipFg: string
37 /** A bar track's edge, so the track reads against any ground. */
38 trackStroke: string
39 /** The cache battery's charge, calm and in its last minute. */
40 batteryFill: string
41 batteryAmber: string
42 /** The cost pill's coin. */
43 coin: string
44 /** The 5-hour and 7-day chips' tints: background, text, and bar and icons. */
45 fiveBg: string
46 fiveFg: string
47 fiveAccent: string
48 /** The 5-hour accent as words, on the card and on its own tint: in light,
49 * fiveAccent holds there only as a mark. */
50 fiveText: string
51 weekBg: string
52 weekFg: string
53 weekAccent: string
54 /** The 7-day accent as words, on the card and on its own tint, as
55 * fiveText is the 5-hour's. */
56 weekText: string
57 /** The departures board: the case behind the tiles, dark in dark and
58 * light alike (plain draws none). */
59 boardBg: string
60 /** A flap tile's face. */
61 flapBg: string
62 /** The hairline across a tile's middle: decoration, no contrast target. */
63 flapSeam: string
64 /** Each item's ink on a tile; the 5h and 7d ones are the chips' accents. */
65 flapItemCache: string
66 flapItemSpend: string
67 flapItemContext: string
68 flapItem5h: string
69 flapItem7d: string
70 /** The board's plain text, its dim words and its warning amber. */
71 flapText: string
72 flapDim: string
73 flapAmber: string
74}
75
76/** Dark's 5-hour and 7-day accents, which the board's tiles wear too. */
77const DARK_FIVE = '#7fcf8a'
78const DARK_WEEK = '#a99cf0'
79
80/** The departures board, dark in dark and light alike: its case, its tiles
81 * and their inks, the 5h and 7d ones dark's accents. */
82const BOARD = {
83 boardBg: '#141414',
84 flapBg: '#262626',
85 flapSeam: '#0E0E0E',
86 flapItemCache: '#7FD48A',
87 flapItemSpend: '#E8C25A',
88 flapItemContext: '#9DB4D0',
89 flapItem5h: DARK_FIVE,
90 flapItem7d: DARK_WEEK,
91 flapText: '#F2EEE3',
92 flapDim: '#B9B4A8',
93 flapAmber: '#F5B14C',
94}
95
96export const DARK: Readonly<Palette> = {
97 filled: true,
98 surface: '#2b2b33',
99 value: '#ececf2',
100 label: '#9a9aa4',
101 warm: '#7fcf8a',
102 cold: '#6f6f7a',
103 amberBg: '#3a2f17',
104 amberFg: '#f0c969',
105 meterTrack: '#45454f',
106 trackInk: '#787889',
107 meterFill: '#b8b8c2',
108 cardBg: '#2a2a31',
109 cardBorder: '#76767f',
110 cardValue: '#c4c4cc',
111 tooltipBg: '#dcdce4',
112 tooltipFg: '#1d1d22',
113 trackStroke: '#7c7c86',
114 batteryFill: '#24402b',
115 batteryAmber: '#5a4719',
116 coin: '#c9a54a',
117 fiveBg: '#1e3324',
118 fiveFg: '#cfe8d3',
119 fiveAccent: DARK_FIVE,
120 fiveText: DARK_FIVE,
121 weekBg: '#2a2540',
122 weekFg: '#d9d3f5',
123 weekAccent: DARK_WEEK,
124 weekText: DARK_WEEK,
125 ...BOARD,
126}
127
128export const LIGHT: Readonly<Palette> = {
129 filled: true,
130 surface: '#ededf2',
131 value: '#1d1d22',
132 label: '#63636e',
133 warm: '#2f8a45',
134 cold: '#a0a0aa',
135 amberBg: '#fbeccd',
136 amberFg: '#7a4e06',
137 meterTrack: '#d6d6de',
138 trackInk: '#82829b',
139 meterFill: '#55555f',
140 cardBg: '#f4f4f7',
141 cardBorder: '#77777f',
142 cardValue: '#3a3a42',
143 tooltipBg: '#2b2b33',
144 tooltipFg: '#ececf2',
145 trackStroke: '#77777f',
146 batteryFill: '#cfe8d3',
147 batteryAmber: '#f3d9a0',
148 coin: '#9a7414',
149 fiveBg: '#dff0e0',
150 fiveFg: '#1f5c2e',
151 fiveAccent: '#2f8a45',
152 fiveText: '#26763b',
153 weekBg: '#e8e4fa',
154 weekFg: '#3c3489',
155 weekAccent: '#6b5fd3',
156 weekText: '#5a4ec4',
157 ...BOARD,
158}
159
160// No backgrounds at all: every colour is a theme key, so it follows whatever
161// theme the user has. The safe fallback, and what NO_COLOR terminals want.
162export const PLAIN: Readonly<Palette> = {
163 filled: false,
164 surface: '',
165 value: 'text',
166 label: 'subtle',
167 warm: 'success',
168 cold: 'subtle',
169 amberBg: '',
170 amberFg: 'warning',
171 meterTrack: 'subtle',
172 trackInk: 'subtle',
173 meterFill: 'text',
174 cardBg: '',
175 cardBorder: 'subtle',
176 cardValue: 'text',
177 tooltipBg: '',
178 tooltipFg: 'text',
179 trackStroke: 'subtle',
180 batteryFill: '',
181 batteryAmber: '',
182 coin: 'warning',
183 fiveBg: '',
184 fiveFg: 'text',
185 fiveAccent: 'success',
186 fiveText: 'success',
187 weekBg: '',
188 weekFg: 'text',
189 weekAccent: 'text',
190 weekText: 'text',
191 boardBg: '',
192 flapBg: '',
193 flapSeam: '',
194 flapItemCache: 'text',
195 flapItemSpend: 'text',
196 flapItemContext: 'text',
197 flapItem5h: 'text',
198 flapItem7d: 'text',
199 flapText: 'text',
200 flapDim: 'text',
201 flapAmber: 'text',
202}
203
204/** The palette NEXTCOST_APPEARANCE names; NO_COLOR forces plain. */
205export const resolvePalette = (appearance: string | undefined, noColor: string | undefined): Readonly<Palette> =>
206 noColor ? PLAIN : appearance === 'light' ? LIGHT : appearance === 'plain' ? PLAIN : DARK
207
208/** Colours for what sits on the band's bare ground, which is the host's and
209 * may be dark or light whatever the palette says. Text there takes theme
210 * keys, its label `inactive`, about 5.8:1 on Claude Code's grounds, where
211 * `subtle` is 2.07:1 in terminal dark; an icon needs hex, so these hold
212 * 3:1 on dark and light grounds alike. The branch icon is the band's one
213 * blue: it means git, never a status. */
214export const BARE = {
215 value: 'text',
216 label: 'inactive',
217 icon: '#80808a',
218 branch: '#5c85d6',
219} as const
220hooks/memory.ts 158 lines1// What the band remembers across sessions, in the plugin's own store: when
2// each session last had a reply and the model it was billed under, what a
3// token costs on each model, the layout the band draws in, and a week's
4// worth of limit samples. With the first two a reopened session, or a
5// reload, says whether its cache is cold and what the next message costs,
6// before any reply of its own, and with the samples the week shows the days
7// before this session. What the store leaves unknown of a last reply is
8// recalled off the transcript.
9
10import type { RecalledReply } from './cache'
11import { MINUTE_MS, SEVEN_DAY_MS } from './format'
12import { modelName } from './prices'
13import { splitLimits } from './snapshot'
14import type { ReportedLimit } from './snapshot'
15import { isRecord, lastReplyAt, lastReplyModel, rateFromTranscript } from './transcript'
16
17/** Store keys. */
18export const SESSIONS_KEY = 'sessions'
19export const RATES_KEY = 'rates'
20export const LAYOUT_KEY = 'layout'
21export const LIMIT_SAMPLES_KEY = 'limitSamples'
22
23/** Sessions kept, newest reply first; the rest are forgotten. */
24export const MAX_SESSIONS = 50
25
26/** A session's last main-loop reply: when it was, and the model it was
27 * billed under, as the API names it. A version before 0.19.0 wrote `lastAt`
28 * alone and reads past `billed`; `billed` is data, read only where used. */
29type SessionRecord = Readonly<{ lastAt: number; billed?: unknown }>
30
31/** Each session's last main-loop reply, by session id. */
32type Sessions = Readonly<Record<string, SessionRecord>>
33
34/** The base input rate per token, solved from a session's own bill, by model. */
35type Rates = Readonly<Record<string, number>>
36
37/** The store's sessions, keeping only well-formed entries: it is data, not trusted. */
38export const asSessions = (v: unknown): Sessions =>
39 isRecord(v)
40 ? Object.fromEntries(
41 Object.entries(v).filter(
42 (entry): entry is [string, SessionRecord] =>
43 isRecord(entry[1]) && typeof entry[1].lastAt === 'number' && Number.isFinite(entry[1].lastAt),
44 ),
45 )
46 : {}
47
48/** The store's rates, keeping only positive finite numbers. */
49export const asRates = (v: unknown): Rates =>
50 isRecord(v)
51 ? Object.fromEntries(
52 Object.entries(v).filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0),
53 )
54 : {}
55
56/** The key a rate is kept and found under: the model it was billed under,
57 * its window aside, as every released version has kept it. */
58const rateKey = (model: string): string => modelName(model)
59
60/** The store's rates with `rate` kept for `model`, the one billed. */
61export const rememberRate = (stored: unknown, model: string, rate: number): Rates => ({ ...asRates(stored), [rateKey(model)]: rate })
62
63/** `sessions` with `id`'s reply at `lastAt`, billed under `billed` where
64 * known, the oldest dropped past MAX_SESSIONS. */
65export const rememberReply = (sessions: Sessions, id: string, lastAt: number, billed?: string): Sessions =>
66 Object.fromEntries(
67 Object.entries({ ...sessions, [id]: billed === undefined ? { lastAt } : { lastAt, billed } })
68 .sort(([, a], [, b]) => b.lastAt - a.lastAt)
69 .slice(0, MAX_SESSIONS),
70 )
71
72/** Both limits at one moment, with the resets that name their windows (epoch ms). */
73export type Sample = Readonly<{ at: number; fivePct: number; sevenPct: number; fiveResetAt: number; sevenResetAt: number }>
74
75const BUCKET_MS = 15 * MINUTE_MS
76/** A week of buckets. */
77export const MAX_SAMPLES = SEVEN_DAY_MS / BUCKET_MS
78
79/** The 15-minute bucket `at` falls in. */
80const bucketOf = (at: number): number => Math.floor(at / BUCKET_MS)
81
82/** Whether two samples fall in one bucket. */
83export const sameBucket = (a: Sample, b: Sample): boolean => bucketOf(a.at) === bucketOf(b.at)
84
85/** Two lists as one: one per bucket, the later winning, sorted, capped. */
86export const mergeSamples = (a: readonly Sample[], b: readonly Sample[]): Sample[] => {
87 const byBucket = new Map<number, Sample>()
88 for (const s of [...a, ...b]) {
89 const bucket = bucketOf(s.at)
90 const had = byBucket.get(bucket)
91 if (had === undefined || s.at >= had.at) byBucket.set(bucket, s)
92 }
93 return [...byBucket.values()].sort((x, y) => x.at - y.at).slice(-MAX_SAMPLES)
94}
95
96/** `samples` with `s` added, as mergeSamples keeps them. */
97export const addSample = (samples: readonly Sample[], s: Sample): Sample[] => mergeSamples(samples, [s])
98
99/** The samples taken by `now`: one dated later, from a clock that ran ahead,
100 * would stay last and open a new bucket every turn. */
101export const samplesUpTo = (samples: readonly Sample[], now: number): Sample[] => samples.filter(s => s.at <= now)
102
103const SAMPLE_FIELDS = ['at', 'fivePct', 'sevenPct', 'fiveResetAt', 'sevenResetAt'] as const
104
105const isSample = (v: unknown): v is Sample => isRecord(v) && SAMPLE_FIELDS.every(k => Number.isFinite(v[k]))
106
107/** The store's samples, keeping only well-formed ones, one per bucket, sorted. */
108export const asLimitSamples = (v: unknown): Sample[] => (Array.isArray(v) ? mergeSamples([], v.filter(isSample)) : [])
109
110/** Both limits now, as one sample; undefined unless each is reported with a reset. */
111export const sampleOf = (now: number, limits: readonly ReportedLimit[]): Sample | undefined => {
112 const { five, seven } = splitLimits(limits)
113 const fiveResetAt = five?.resetsAt
114 const sevenResetAt = seven?.resetsAt
115 if (five === undefined || seven === undefined || fiveResetAt === undefined || sevenResetAt === undefined) return undefined
116 return { at: now, fivePct: five.percentUsed, sevenPct: seven.percentUsed, fiveResetAt, sevenResetAt }
117}
118
119/** What the band's own store recalls of a session's last reply: when it
120 * was, the model it was billed under, and the rate on that model, else on
121 * the model /model names; with every rate stored, for the model a
122 * transcript names. */
123type StoreRecall = Readonly<{ rates: Rates; model: string; lastAt: number | undefined; billed: string | undefined; rate: number | null }>
124
125/** The billed model a session's record names; undefined for anything else. */
126const billedOf = (record: SessionRecord | undefined): string | undefined =>
127 typeof record?.billed === 'string' && record.billed !== '' ? record.billed : undefined
128
129/** What the store recalls of `sessionId`'s last reply, on `model` as /model
130 * names it. With the billed model recorded, the rate is the one kept under
131 * that name; a record from before 0.19.0 names none, and the rate under the
132 * /model name stands in, which is the billed one when /model names it in full. */
133export const fromStore = (stored: Readonly<{ sessions: unknown; rates: unknown }>, sessionId: string, model: string): StoreRecall => {
134 const rates = asRates(stored.rates)
135 const record = asSessions(stored.sessions)[sessionId]
136 const billed = billedOf(record)
137 const key = rateKey(model)
138 return { rates, model: key, lastAt: record?.lastAt, billed, rate: rates[billed === undefined ? key : rateKey(billed)] ?? null }
139}
140
141/** Whether the store leaves a fact unknown that the transcript may give. */
142export const needsTranscript = (r: StoreRecall): boolean => r.lastAt === undefined || r.rate === null
143
144/** The last reply as recalled: the store's facts first; what they leave
145 * unknown off the transcript's end, when one was read: its last reply's
146 * time and the model it was billed under, and the rate on that model, as
147 * stored under its name or else solved from the transcript's cost record. */
148export const recallOf = (r: StoreRecall, transcript: string | undefined): RecalledReply => {
149 if (transcript === undefined) return { lastAt: r.lastAt, rate: r.rate, billed: r.billed }
150 // /model may name an alias; the last reply names the model it was billed under.
151 const billed = lastReplyModel(transcript) ?? r.billed
152 return {
153 lastAt: r.lastAt ?? lastReplyAt(transcript),
154 rate: r.rate ?? (billed === undefined ? rateFromTranscript(transcript, r.model) : (r.rates[rateKey(billed)] ?? rateFromTranscript(transcript, billed))),
155 billed,
156 }
157}
158hooks/snapshot.ts 160 lines1// The snapshot register.tsx reads for the band, and what the band can do
2// back: the one contract between the engine side and the drawing, and the
3// one way to build it. With it, the layouts' names, and those a stored
4// layout or the command may still name.
5
6import type { RenderSurface, SessionRateLimit, SessionUsage } from 'claude-code'
7import type { CacheView } from './cache'
8import { parseReset } from './format'
9import type { Zone } from './format'
10import type { Trails } from './insights'
11import type { Sample } from './memory'
12import type { Palette } from './palette'
13import type { Workspace } from './workspace'
14
15/** The glyph tier: the band's own glyphs, or ascii alone where a terminal
16 * draws ambiguous-width glyphs two columns wide. */
17export type Glyphs = 'unicode' | 'ascii'
18
19/** Every layout the band can be drawn in. */
20export const LAYOUT_NAMES = ['chips', 'pulse', 'departures'] as const
21export type LayoutName = (typeof LAYOUT_NAMES)[number]
22/** The layout drawn until /nextcost layout chooses another. */
23export const DEFAULT_LAYOUT: LayoutName = 'chips'
24/** Layouts this version removed: a stored one draws chips, and the command names it. */
25const REMOVED_LAYOUTS = ['gauges', 'ledger', 'rings', 'tiles', 'week', 'forecast'] as const
26export const REMOVED_IN = '0.14.0'
27
28/** A name from the store or the command, trimmed, in any case. */
29const nameOf = (v: unknown): string | undefined => (typeof v === 'string' ? v.trim().toLowerCase() : undefined)
30/** A layout's name from the store or the command; anything else, a name
31 * from a newer version included, is undefined. */
32export const asLayoutName = (v: unknown): LayoutName | undefined => {
33 const name = nameOf(v)
34 return LAYOUT_NAMES.find(n => n === name)
35}
36/** A removed layout's name from the store or the command; else undefined. */
37export const removedLayoutOf = (v: unknown): string | undefined => {
38 const name = nameOf(v)
39 return REMOVED_LAYOUTS.find(n => n === name)
40}
41
42/** A limit window's use, and when it resets, epoch ms; undefined without a readable time. */
43export type LimitUse = Readonly<{ percentUsed: number; resetsAt: number | undefined }>
44/** A window the engine reports, by its kind: `five_hour`, `spend_limit`. */
45export type ReportedLimit = LimitUse & Readonly<{ kind: string }>
46
47/** A window the engine reports, its reset parsed once, here: past this
48 * point every reset is epoch ms. */
49export const limitOf = (l: SessionRateLimit): ReportedLimit => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: parseReset(l.resetsAt) })
50
51/** The engine's names for the two limits the band reads by name. */
52export const FIVE_HOUR = 'five_hour'
53const SEVEN_DAY = 'seven_day'
54
55/** The 5-hour and 7-day windows the engine reports, and every other one. */
56export const splitLimits = <L extends Readonly<{ kind: string }>>(limits: readonly L[]): Readonly<{ five: L | undefined; seven: L | undefined; others: L[] }> => ({
57 five: limits.find(l => l.kind === FIVE_HOUR),
58 seven: limits.find(l => l.kind === SEVEN_DAY),
59 others: limits.filter(l => l.kind !== FIVE_HOUR && l.kind !== SEVEN_DAY),
60})
61
62/** Everything the band shows, read by register.tsx. */
63export type BandSnapshot = Readonly<{
64 surface: RenderSurface
65 columns: number
66 /** Rows the band may take before it scrolls. */
67 maxRows: number
68 isWorking: boolean
69 expanded: boolean
70 palette: Readonly<Palette>
71 now: number
72 cache: CacheView
73 costUsd: number
74 /** This conversation's message costs and context: the newest cost is
75 * what the last message cost. */
76 history: Trails
77 context: Readonly<{
78 tokens: number | undefined
79 window: number
80 percent: number | undefined
81 /** Where auto-compaction runs, when it is on. */
82 compactAt: number | undefined
83 /** Auto-compaction is off, as the engine said; false while unknown. */
84 autoCompactOff: boolean
85 }>
86 fiveHour: (LimitUse & { etaMs: number | null }) | undefined
87 sevenDay: LimitUse | undefined
88 /** The newest limit samples, a week's worth of 15-minute buckets, every
89 * session's, oldest first. Sparse use may stretch them back past a week. */
90 samples: readonly Sample[]
91 /** Any other window the engine reports, such as a gateway's spend_limit. */
92 otherLimits: readonly ReportedLimit[]
93 /** The project, home-relative, and git there; undefined until first read. */
94 workspace: Workspace | undefined
95 /** The layout chosen with /nextcost layout. */
96 layout: LayoutName
97 /** The glyph tier, resolved once per session. */
98 glyphs: Glyphs
99 /** The local zone, its offset read at each instant drawn; undefined when
100 * the clock gives no offset, and the band counts down instead. */
101 zone: Zone | undefined
102}>
103
104export type BandActions = Readonly<{
105 toggleExpanded: () => Promise<void>
106 hide: () => Promise<void>
107}>
108
109/** What a snapshot is built from: the engine's usage at `now` and the
110 * render's mount; what the band keeps between hooks; and the cache's view
111 * and the insights as they stand then, the ledger already noted. */
112export type SnapshotParts = Readonly<{
113 usage: Pick<SessionUsage, 'rateLimits' | 'context'>
114 now: number
115 mount: Pick<BandSnapshot, 'surface' | 'columns' | 'maxRows' | 'isWorking'>
116 expanded: boolean
117 band: Pick<BandSnapshot, 'palette' | 'layout' | 'glyphs' | 'samples' | 'workspace'> & Pick<BandSnapshot['context'], 'compactAt' | 'autoCompactOff'>
118 cache: CacheView
119 costUsd: number
120 history: Trails
121 /** Time until your pace fills the 5-hour window, as insights.ts measures it. */
122 etaMs: number | null
123 zone: Zone | undefined
124}>
125
126/** The snapshot of `p`: each window the engine reports read once, the 5h
127 * and 7d by name, and the context with where it compacts. */
128export const snapshotOf = (p: SnapshotParts): BandSnapshot => {
129 const { five, seven, others } = splitLimits(p.usage.rateLimits.map(limitOf))
130 const { band, usage } = p
131 return {
132 ...p.mount,
133 expanded: p.expanded,
134 palette: band.palette,
135 layout: band.layout,
136 glyphs: band.glyphs,
137 now: p.now,
138 cache: p.cache,
139 costUsd: p.costUsd,
140 history: p.history,
141 context: {
142 tokens: usage.context.tokens,
143 window: usage.context.window,
144 percent: usage.context.percent,
145 compactAt: band.compactAt,
146 autoCompactOff: band.autoCompactOff,
147 },
148 fiveHour: five ? { percentUsed: five.percentUsed, resetsAt: five.resetsAt, etaMs: p.etaMs } : undefined,
149 sevenDay: seven ? { percentUsed: seven.percentUsed, resetsAt: seven.resetsAt } : undefined,
150 samples: band.samples,
151 workspace: band.workspace,
152 zone: p.zone,
153 otherLimits: others,
154 }
155}
156
157/** The resets a snapshot draws, epoch ms: the timer repaints as their countdowns turn. */
158export const resetsOf = (s: BandSnapshot): number[] =>
159 [s.fiveHour, s.sevenDay, ...s.otherLimits].flatMap(l => (l?.resetsAt === undefined ? [] : [l.resetsAt]))
160hooks/toast.ts 82 lines1// When the band speaks a toast, and what it says: the 5-hour limit's and the
2// context's, each once per threshold crossed. Pure: register.tsx speaks the
3// texts and keeps the levels reached.
4
5import { SEVERE_AT, WARN_AT, contextPressure, fmtPct, toCompaction } from './format'
6import { FIVE_HOUR } from './snapshot'
7import { roomOf } from './words'
8
9/** Each toast's level reached, by its key, so it speaks once per crossing. */
10export type Warned = ReadonlyMap<string, number>
11/** The levels to remember, and the texts to speak, in order. */
12export type Toasts = Readonly<{ warned: Warned; say: readonly string[] }>
13
14/** No toast has spoken. */
15export const NO_WARNINGS: Warned = new Map()
16
17/** The context's keys, one per scale it is measured on: its levels toward
18 * the window and toward compaction are not one count. */
19const CONTEXT = { window: 'context', compaction: 'context toward compaction' } as const
20
21/** A toast re-arms once its figure falls back below this share. */
22const TOAST_REARM_BELOW = 0.75
23
24/** Whether a toast speaks, and the level to remember: it speaks once per
25 * threshold crossed (at the highest reached), holds while the figure stays
26 * up, and re-arms, back to level 0, once the figure falls under the re-arm
27 * share. `prev` is the level remembered from before. */
28const escalate = (prev: number, frac: number, levels: readonly number[]): Readonly<{ level: number; speak: boolean }> => {
29 const level = levels.filter(at => frac >= at).length
30 if (level > prev) return { level, speak: true }
31 return { level: frac < TOAST_REARM_BELOW ? 0 : prev, speak: false }
32}
33
34/** One toast's figure `frac` against its `levels`, under `key`: the level
35 * it reaches, and its text, given the figure as a percent, when it speaks. */
36const note = (warned: Warned, key: string, frac: number, levels: readonly number[], text: (pct: string) => string): Toasts => {
37 const { level, speak } = escalate(warned.get(key) ?? 0, frac, levels)
38 return { warned: new Map(warned).set(key, level), say: speak ? [text(fmtPct(frac))] : [] }
39}
40
41/** The 5-hour limit's toasts, from the windows the engine reports. */
42export const limitToasts = (warned: Warned, limits: ReadonlyArray<Readonly<{ kind: string; percentUsed: number }>>): Toasts =>
43 limits
44 .filter(l => l.kind === FIVE_HOUR)
45 .reduce<Toasts>(
46 (t, l) => {
47 const next = note(t.warned, FIVE_HOUR, l.percentUsed / 100, [WARN_AT, SEVERE_AT], pct => `You've used ${pct} of your 5-hour limit.`)
48 return { warned: next.warned, say: [...t.say, ...next.say] }
49 },
50 { warned, say: [] },
51 )
52
53/** The context as a toast reads it: the tokens in use, undefined when the
54 * engine reports none, the window, where auto-compaction runs when it is
55 * on and known, and whether it is off. */
56export type ContextNow = Readonly<{ used: number | undefined; window: number; compactAt: number | undefined; autoCompactOff: boolean }>
57
58/** The context's toast. It speaks where the context pill turns amber: by the
59 * same rule. A level counts on the scale it was reached on, and each scale
60 * keeps its own: the one measured escalates and re-arms on its figure, and
61 * the other holds, so a compaction point the engine gives and drops by
62 * turns never speaks the same warning twice. */
63export const contextToasts = (warned: Warned, c: ContextNow): Toasts => {
64 const { used, compactAt } = c
65 if (used === undefined) return { warned, say: [] }
66 const { frac, levels } = contextPressure(used, c.window, compactAt)
67 return note(warned, compactAt === undefined ? CONTEXT.window : CONTEXT.compaction, frac, levels, pct =>
68 compactAt !== undefined ? `Auto-compaction in ${roomOf(toCompaction(used, compactAt))} tokens.`
69 : c.autoCompactOff ? `Context is ${pct} full and auto-compaction is off, so the conversation will run out of room.`
70 : `Context is ${pct} full.`,
71 )
72}
73
74/** The warnings once a conversation ends: the context's start over; the
75 * 5-hour limit is the account's, and /clear changes nothing about it. */
76export const forgetConversation = (warned: Warned): Warned => {
77 const kept = new Map(warned)
78 kept.delete(CONTEXT.window)
79 kept.delete(CONTEXT.compaction)
80 return kept
81}
82