SLOPSHOPPER

nextcost

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.

newbandcommandtoastprocesstimer
★ 5v0.19.0MITupdated 2026-10-22HMarzban/nextcost/plugins/nextcost
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nextcost
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /nextcost ⎿ nextcost: nextcost hidden. /nextcost shows it again. ◷ cache – $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ cache – $0.42
README

nextcost

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:

The chip row, labelled: the cache countdown and what a re-warm will cost, the session cost, context toward auto-compaction, the 5-hour limit with its pace, the weekly limit and its reset, and the toggle that opens the cards

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.

Install

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.

Settings

CommandEffect
/nextcostToggle visibility
/nextcost more / lessOpen or close the expanded view, and show the band if hidden
/nextcost show / hideSet visibility explicitly
/nextcost layout <name>Draw the band in another layout, for every session, and show it if hidden
/nextcost layoutName the layout in use and list them all
VariableEffect
NEXTCOST_APPEARANCEdark (default), light or plain; see Appearance
NO_COLORForces plain
NEXTCOST_GLYPHSascii or unicode, in the terminal only; see Glyphs in the terminal
CLAUDE_CODE_PROMPT_CACHE_TTL5m or 1h, so the band doesn't guess the cache's lifetime; see Cache lifetime
FORCE_PROMPT_CACHING_5M=1The same, for 5 minutes
ENABLE_PROMPT_CACHING_1H=1The 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.

Reading it

This section describes chips, the default layout; the others are under Layouts.

ChipShowsTurns amber when
CacheA 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 yetIts last minute: 0:47 left · re-warm ~$0.52
CostThe session's total so farNever
ContextHow full the conversation is toward auto-compaction (63% full), or of the model window when compaction is offNear compaction: 95% full · compacts in ~8k. Without auto-compaction: 80% (!), 95% (!!)
5hYour 5-hour limit: usage on a bar, and the reset80%, or when your pace would fill it before it resets: full in ~40m
7dYour weekly limit, the same way80%
TokensEvery token this conversation usedNever

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
PieceShows
PathThe project, home as ~, its folder in bold
BranchThe git branch, or detached at <commit>
Worktreeworktree of <repo> in a linked worktree
ChangesFiles with uncommitted changes, or clean
Ahead / behindCommits 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:

The band expanded on the desktop: chips for the cache, cost, context and the 5h and 7d limits, then the Cache, Spend, Context and Limits cards, the project path and branch, and the Collapse and Hide band buttons

CardShows
CacheTime 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
SpendThe session total, a bar of the token split with its legend (input, output, cache reads), your last message
ContextHow full toward auto-compaction, tokens in context, where compaction runs, room left, the model window
LimitsThe 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.

When the band is narrow

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.

Layouts

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">

LayoutAsks, and showsRows (desktop / terminal)
chipsIs anything wrong right now? The row of chips above: the cache, the spend, the context and the limits at a glance; the default1 / 1
pulseIs 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 day1 / 1
departuresWhen 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 chips1 / 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.

Glyphs in the terminal

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.

Clock times

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

The cache countdown

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.

Reopening an old session

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:

  • What the session spent before you resumed it: its last cost record, and each reply logged after it, priced at its model's rate in that record. The cost shows that plus what you've spent since resuming, or Claude Code's own total if it is larger; the two totals are never added. The tokens chip and the Spend card's breakdown count from it too, rather than waiting for your next message.
  • How long the cache was last written for: an hour or 5 minutes, from the last reply in the main conversation that wrote to it. Until the band's first reply, the Cache card says 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:

  • from its own memory, which keeps each session's last reply and the model it was billed under (the newest 50), and the price per token it solved for each model
  • for a session from before the band, from the end of that session's transcript, read once: the last reply's time and model, and the cost record's dollars and tokens

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

What a cold cache costs

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

  • Writes at 1.25×, as Claude Code counts them. Claude Code's cost ledger prices every cache write at 1.25×, so the band does too and agrees with the cost it shows. Anthropic bills a 1-hour cache write at 2×, so if you pay per token on a 1-hour cache, the real re-warm is up to 1.6× the estimate. Paying per token usually means the 5-minute cache, where 1.25× is exact.
  • After /clear, a resume or a reload, the rate is solved from what the current conversation has spent, not the session's whole ledger.
  • Claude Haiku 5.5 has two price tiers. A request whose prompt (every input token, cache reads and writes included) is over 100,000 tokens pays 5× every price ($0.50 per million input tokens rather than $0.10) (long context pricing). The band weighs each request at its own tier, so 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.

Your pace on the 5-hour limit

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.

Toasts

A toast speaks where a chip turns amber:

  • the 5-hour limit at 80% and 95%
  • context within 10% of auto-compaction, or at 80% and 95% when auto-compaction is off

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.

Appearance

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.

Cache lifetime

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=1
  • CLAUDE_CODE_PROMPT_CACHE_TTL=5m or 1h
  • "promptCacheTtl": "5m" or "1h" in your settings.json
  • ENABLE_PROMPT_CACHING_1H=1

Idle compaction

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

What it runs, reads and sends

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:

CommandWhereWhy
git -c core.fsmonitor=false --no-optional-locks status --porcelain=v2 --branchthe session's project folderthe 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-toplevelthe samewhether 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

Source 39 files
hooks/register.tsx 650 lines
1// 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}
650
hooks/band.tsx 48 lines
1// 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}
48
hooks/cache.ts 621 lines
1// 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}
621
hooks/cache-settings.ts 47 lines
1// 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}
47
hooks/command.ts 104 lines
1// 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}
104
hooks/format.ts 287 lines
1// 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('|')
287
hooks/glyphs.ts 75 lines
1// 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}
75
hooks/insights.ts 122 lines
1// 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}
122
hooks/palette.ts 220 lines
1// 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
220
hooks/memory.ts 158 lines
1// 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}
158
hooks/snapshot.ts 160 lines
1// 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]))
160
hooks/toast.ts 82 lines
1// 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