Context window details like the Desktop app's panel: /ctx opens a pane that docks beside the transcript, and a 📊 button above the prompt toggles it.

Personal Claude Code mods: plugins of function hooks that draw panes, bands and footers inside Claude Code. This repo is also a plugin marketplace, so every computer installs the same mods the same way.
| Mod | What it does |
|---|---|
ctx | Context window details in a pane and a band above the prompt: how full, how fast it is filling, what filled it, compacting on your terms, and what each of those costs. Why each feature exists: ctx/DESIGN.md. |
claude plugin marketplace add afterever/claude-mods
claude plugin install ctx@afterever-mods
Before the repo is on GitHub, add it from its folder instead:
claude plugin marketplace add C:\github\claude-mods
claude plugin install ctx@afterever-mods
Pick up new versions with claude plugin marketplace update afterever-mods and then claude plugin update ctx@afterever-mods (restart Claude Code to apply).
| Command or key | Does | |
|---|---|---|
/ctx, or 📊 / x on the band | Open or close the pane | |
/ctx exact, /ctx quick (e in the pane) | Count with the token API, or estimate locally (the default) | |
/ctx compact [focus] (c) | Compact now, keeping your focus, edited files and latest requests | |
| `/ctx autokeep on | off (k`) | Add the same to every compaction, auto-compact included (off by default) |
| `/ctx cost on | off (p`) | Show or hide the dollar figures: the last turn's cost, carrying cost, compaction payback, idle overhead (on by default) |
m f s a h d in the pane | Open MCP tools, memory files, skills, agents, heaviest results, unused so far | |
r in the pane | Count again |
Edit a mod in place and load it from disk, which hot-reloads on save:
claude --plugin-dir C:\github\claude-mods\ctx
Check a mod before committing:
claude plugin validate C:\github\claude-mods
claude plugin test C:\github\claude-mods\ctx
Claude Code writes ctx/.claude-plugin/types/ and ctx/tsconfig.json each time it loads a mod. They are regenerated and git-ignored.
Bump version in the mod's plugin.json when you change it, or installed copies will not pick the change up.
.claude-plugin/marketplace.json the marketplace listing
ctx/
DESIGN.md why each feature exists and how it is built
.claude-plugin/plugin.json name, version, state contract
hooks/hooks.json points at register.tsx
hooks/register.tsx the mod
hooks/fmt.ts, fmt.test.ts pure formatting helpers and their tests
hooks/usage.ts, usage.test.ts tool-result sizes, usage counts, the project log
hooks/compact.ts, compact.test.ts keep instructions and the auto-compact warning
hooks/cost.ts, cost.test.ts per-turn spend, learned prices, carrying and compaction costs
hooks/render.test.ts the pane, band and footer drawn on terminal and desktop
types/index.d.ts the mod's $.state contracthooks/register.tsx 1091 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextBreakdown, Timer } from 'claude-code'
3
4import type { CtxDetail, CtxProjectLog, CtxSample, CtxServer, CtxSnapshot, CtxSpend, CtxWarn } from '../types'
5import type { BarRun } from './fmt'
6import type { ApiUsage, Piece, Rates } from './cost'
7import { addRecent, clipAsk, keepInstructions, mergeInstructions, shouldWarn, warnBadge, warnLevel, warnText } from './compact'
8import { addEater, addUsage, calibrate, DEFAULT_CPT, EMPTY_USAGE, estimateTokens, idleSessions, logSession, toolLabel, usageOf } from './usage'
9import { VERSION } from './version'
10import {
11 bandSuffix,
12 carry,
13 carryLine,
14 compacted as noteCompacted,
15 compactPlan,
16 EMPTY_SPEND,
17 fitRates,
18 fmtIdle,
19 fmtUsd,
20 idleCost,
21 ledgerAt,
22 median,
23 missLine,
24 rewrite,
25 sessionParts,
26 shownTurn,
27 startTurn,
28 stepDone,
29 subUsd,
30 ttlMs,
31 turnDone,
32 turnLine,
33} from './cost'
34import {
35 ago,
36 barRuns,
37 BUFFER_GLYPH,
38 compactMarker,
39 fillColor,
40 FREE_GLYPH,
41 fmtCount,
42 fmtDelta,
43 fmtShare,
44 fmtTokens,
45 openerFor,
46 shareBar,
47 shortPath,
48 sparkline,
49 trend,
50 trendLabel,
51} from './fmt'
52
53const PANE = 'ctx'
54// How often an open pane redraws its "updated 12s ago"; no count runs on it
55const TICK_MS = 15000
56const SKILL_LIMIT = 12
57const GAUGE = 6
58// Bar runs and category rows share a hover group per category
59const SCOPE = 'ctx:'
60
61// What the drawings read lives in $.state, so a hot reload keeps it
62const snap = atom({ plugin: 'ctx', key: 'snap' } as const, null)
63const isVisible = atom({ plugin: 'ctx', key: 'isVisible' } as const, false)
64const opened = atom({ plugin: 'ctx', key: 'opened' } as const, [])
65const mode = atom({ plugin: 'ctx', key: 'mode' } as const, 'summary')
66const stale = atom({ plugin: 'ctx', key: 'stale' } as const, false)
67const tick = atom({ plugin: 'ctx', key: 'tick' } as const, 0)
68// The window's fill after each turn, oldest first, as the API reported it
69const history = atom({ plugin: 'ctx', key: 'history' } as const, [])
70const HISTORY_LIMIT = 48
71const eaters = atom({ plugin: 'ctx', key: 'eaters' } as const, [])
72const cptAtom = atom({ plugin: 'ctx', key: 'cpt' } as const, DEFAULT_CPT)
73const usage = atom({ plugin: 'ctx', key: 'usage' } as const, EMPTY_USAGE)
74const project = atom({ plugin: 'ctx', key: 'project' } as const, null)
75// A single result past this many tokens gets a toast as it lands
76const TOAST_TOKENS = 15000
77// Results smaller than this never make the heaviest list
78const EATER_MIN_CHARS = 2000
79// A fall this steep between turns is a compaction or a /clear: what was eaten is gone
80const DROP_RATIO = 0.7
81
82// Characters of tool results the main conversation took in since the last measurement
83let turnChars = 0
84
85const projectKey = async ($: EngineInterface) => 'project:' + (await $.session.cwd())
86
87// Writes this session's line in the project's log: the servers it has loaded, the ones it called
88async function logProject($: EngineInterface) {
89 try {
90 const s = await read($, snap)
91 if (!s) return
92 const loaded: Record<string, number> = {}
93 for (const v of s.servers) if (v.tokens > 0) loaded[v.key] = v.tokens
94 const used = Object.keys((await read($, usage)).mcp)
95 const { requests, misses } = await read($, spend)
96 const key = await projectKey($)
97 const log = (await $.store.get(key)) as CtxProjectLog | undefined
98 await $.store.set(key, logSession(log, await $.session.id(), loaded, used, { requests, misses }))
99 } catch {
100 // the log is a convenience: the pane still counts this session
101 }
102}
103
104const compacted = atom({ plugin: 'ctx', key: 'compacted' } as const, [])
105const edited = atom({ plugin: 'ctx', key: 'edited' } as const, [])
106const asks = atom({ plugin: 'ctx', key: 'asks' } as const, [])
107const autokeep = atom({ plugin: 'ctx', key: 'autokeep' } as const, false)
108const warned = atom({ plugin: 'ctx', key: 'warned' } as const, 'none')
109const EDIT_LIMIT = 12
110const ASK_LIMIT = 3
111// What the session spent, per turn, from the engine's cost ledger
112const spend = atom({ plugin: 'ctx', key: 'spend' } as const, EMPTY_SPEND)
113const costOn = atom({ plugin: 'ctx', key: 'costOn' } as const, true)
114// A compaction's summary when none has been seen yet, in tokens
115const SUMMARY_TOKENS = 8000
116
117// The prices of the model the main loop answers with, once the ledger has taught them
118function ratesOf(s: CtxSpend): Rates | undefined {
119 const model = shownTurn(s)?.model || s.cur?.model
120 return model ? fitRates(s.samples, model) : undefined
121}
122
123// What the window will be after a compaction: what the last one left, else
124// everything but the messages plus a summary
125function afterCompact(s: CtxSpend, snapshot: CtxSnapshot | null): number | undefined {
126 if (s.compact?.after) return s.compact.after
127 const messages = snapshot?.rows.find(r => r.name === 'Messages')
128 if (!snapshot || !messages) return undefined
129 const fixed = snapshot.rows.filter(r => r.kind === 'used' && r !== messages).reduce((a, r) => a + r.tokens, 0)
130 return fixed + (s.compact?.summary ?? SUMMARY_TOKENS)
131}
132
133// Main-loop requests per finished turn
134function perTurn(s: CtxSpend): number | undefined {
135 return s.turns > 0 ? s.requests / s.turns : undefined
136}
137
138// After a compaction the ledger says what it cost, and the result how big the window is now
139async function priceCompaction($: EngineInterface, r: { tokensAfter?: number; usage?: ApiUsage }, ledger: number | undefined) {
140 try {
141 const now = (await $.session.usage()).cost?.usd
142 const usd = now !== undefined && ledger !== undefined && now > ledger ? now - ledger : undefined
143 await update($, spend, s => noteCompacted(now !== undefined ? ledgerAt(s, now) : s, { after: r.tokensAfter, usd, summary: r.usage?.output_tokens }))
144 } catch {
145 // the toast goes out without the price
146 }
147}
148
149// A compaction happened since the last reading: the next one carries the mark
150let pendingMark = false
151// The mod's own compaction is under way: compactNow follows it up, so the
152// session.compact hook must not do it a second time should it hear it
153let selfCompact = false
154
155// The last `n` flags, padded in front: readings from before marks existed read as unmarked
156function alignFlags(flags: readonly boolean[], n: number): boolean[] {
157 return [...Array<boolean>(Math.max(0, n - flags.length)).fill(false), ...flags.slice(-n)]
158}
159
160async function record($: EngineInterface, tokens: number) {
161 const past = await read($, history)
162 if (past[past.length - 1] === tokens) return
163 const mark = pendingMark
164 pendingMark = false
165 await update($, compacted, flags => [...alignFlags(flags, past.length), mark].slice(-HISTORY_LIMIT))
166 await update($, history, list => [...list, tokens].slice(-HISTORY_LIMIT))
167}
168
169// The newest reading of the window, the API's when there is one
170async function latestTokens($: EngineInterface): Promise<number | undefined> {
171 const past = await read($, history)
172 return past[past.length - 1] ?? (await read($, snap))?.total
173}
174
175// How close auto-compaction is right now, with the turns left at this pace
176async function warning($: EngineInterface): Promise<{ level: CtxWarn; tokens: number; compactAt?: number; turnsLeft?: number }> {
177 const s = await read($, snap)
178 const tokens = (await latestTokens($)) ?? 0
179 const compactAt = s?.compactAt
180 const turnsLeft = trend(await read($, history), compactAt).turnsLeft
181 return { level: warnLevel(tokens, compactAt, turnsLeft), tokens, compactAt, turnsLeft }
182}
183
184async function keepText($: EngineInterface, focus?: string): Promise<string> {
185 return keepInstructions({ focus, edited: await read($, edited), asks: await read($, asks) })
186}
187
188// Once a compaction stands: the next reading is marked, what was eaten and
189// warned of starts over, and the drop is toasted when the new count lands
190async function afterCompaction($: EngineInterface, before: number | undefined) {
191 pendingMark = true
192 await update($, eaters, () => [])
193 await update($, warned, () => 'none')
194 await refresh($)
195 const s = await read($, snap)
196 if (before === undefined || !s) return
197 let text = `Compacted ${fmtTokens(before)} → ~${fmtTokens(s.total)} (${fmtDelta(s.total - before)})`
198 if (await read($, costOn)) {
199 const sp = await read($, spend)
200 const r = ratesOf(sp)
201 if (sp.compact?.usd) text += ` · summary ${fmtUsd(sp.compact.usd)}`
202 if (r) text += ` · now ~${fmtUsd(carry(s.total, r))}/request (was ~${fmtUsd(carry(before, r))})`
203 }
204 $.ui.toast(text)
205}
206
207// Compacts now, the summary told what this session is in the middle of. The
208// mod's own session.compact hook does not hear its own call, so this follows up itself
209async function compactNow($: EngineInterface, focus?: string) {
210 $.ui.toast('Compacting…')
211 try {
212 const before = await latestTokens($)
213 const ledger = (await read($, spend)).ledger
214 selfCompact = true
215 const r = await $.session.compact({ instructions: await keepText($, focus) }).finally(() => {
216 selfCompact = false
217 })
218 if ('skip' in r && r.skip) $.ui.toast(`Compaction skipped: ${r.skip}`)
219 else if (r.messages) {
220 await priceCompaction($, r, ledger)
221 await afterCompaction($, before)
222 }
223 } catch {
224 $.ui.toast('Could not compact right now')
225 }
226}
227
228async function setAutokeep($: EngineInterface, value: boolean) {
229 await update($, autokeep, () => value)
230 try {
231 await $.store.set('autokeep', value)
232 } catch {
233 // on for this session still
234 }
235 $.ui.toast(value ? 'Autokeep on: every compaction keeps your files and latest requests' : 'Autokeep off')
236}
237
238async function setCost($: EngineInterface, value: boolean) {
239 await update($, costOn, () => value)
240 try {
241 await $.store.set('cost', value)
242 } catch {
243 // for this session still
244 }
245 $.ui.toast(value ? 'Cost on: dollar figures beside the tokens' : 'Cost off: tokens only')
246}
247
248// Opens a memory file with the host's default app for it
249async function openFile($: EngineInterface, path: string) {
250 const name = shortPath(path)
251 try {
252 const home = (await $.env.get('HOME')) ?? ''
253 const os =
254 (await $.env.get('OS')) === 'Windows_NT' || /^[A-Za-z]:[\\/]/.test(path)
255 ? 'windows'
256 : path.startsWith('/Users/') || home.startsWith('/Users/')
257 ? 'mac'
258 : 'linux'
259 const { exitCode, stderr } = await $.process.run(openerFor(path, os), { timeoutMs: 15000 })
260 await $.ui.toast(exitCode === 0 ? `Opened ${name}` : `Couldn't open ${name}: ${stderr.trim().split('\n')[0] || `exit ${exitCode}`}`)
261 } catch {
262 // no host to run on (the Desktop app), or the opener is missing
263 await $.ui.toast(`Couldn't open ${name}`)
264 }
265}
266
267function toSnapshot(b: SessionContextBreakdown, detail: CtxDetail, at: number): CtxSnapshot {
268 const byServer = new Map<string, CtxServer>()
269 for (const t of b.mcpTools) {
270 const key = /^mcp__(.+?)__/.exec(t.name)?.[1] ?? t.serverName
271 const s = byServer.get(t.serverName) ?? { name: t.serverName, key, tokens: 0, count: 0, deferredTokens: 0, deferredCount: 0 }
272 if (t.isLoaded) {
273 s.tokens += t.tokens
274 s.count += 1
275 } else {
276 s.deferredTokens += t.tokens
277 s.deferredCount += 1
278 }
279 byServer.set(t.serverName, s)
280 }
281 const servers = [...byServer.values()].sort((x, y) => y.tokens - x.tokens || y.deferredTokens - x.deferredTokens)
282 const sum = (xs: { tokens: number }[]) => xs.reduce((a, x) => a + x.tokens, 0)
283 return {
284 rows: b.categories.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
285 total: b.totalTokens,
286 max: b.rawMaxTokens,
287 percent: b.percentage,
288 detail,
289 at,
290 model: b.model,
291 compactAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
292 mcpTokens: sum(servers),
293 mcpCount: servers.reduce((a, s) => a + s.count, 0),
294 mcpDeferredTokens: servers.reduce((a, s) => a + s.deferredTokens, 0),
295 mcpDeferredCount: servers.reduce((a, s) => a + s.deferredCount, 0),
296 servers,
297 memory: b.memoryFiles.map(f => ({ path: f.path, type: f.type, tokens: f.tokens })),
298 skillCount: b.skills?.totalSkills ?? 0,
299 skillIncluded: b.skills?.includedSkills ?? 0,
300 skillTokens: b.skills?.tokens ?? 0,
301 skills: (b.skills?.skillFrontmatter ?? []).map(s => ({ name: s.name, tokens: s.tokens })).sort((x, y) => y.tokens - x.tokens),
302 agentTokens: sum(b.agents),
303 agents: b.agents.map(a => ({ name: a.agentType, tokens: a.tokens })).sort((x, y) => y.tokens - x.tokens),
304 commandCount: b.slashCommands?.totalCommands ?? 0,
305 commandIncluded: b.slashCommands?.includedCommands ?? 0,
306 commandTokens: b.slashCommands?.tokens ?? 0,
307 }
308}
309
310// Counts can overlap (a turn ends while an exact count is out): the one
311// started last wins, so a slow older count never overwrites a newer one
312let started = 0
313let landed = 0
314
315// Counts at the mode the person chose, so an exact count stays exact
316async function refresh($: EngineInterface) {
317 const detail = await read($, mode)
318 const id = ++started
319 try {
320 const { context } = await $.session.usage({ breakdown: detail })
321 const b = context.breakdown
322 if (!b || id < landed) return
323 landed = id
324 const at = await $.clock.now()
325 await update($, snap, () => toSnapshot(b, detail, at))
326 await update($, stale, () => false)
327 } catch {
328 // no session bound yet, or the count failed: the last snapshot stays, marked
329 if (await read($, snap)) await update($, stale, () => true)
330 }
331}
332
333// The pane docks beside the transcript in the fullscreen layout (from 110
334// columns), like /diff; on the main screen it sits above the prompt instead
335const PANE_ARGS = { id: PANE, title: 'Context window', closeOnEscape: true, columns: 48, rows: 26 } as const
336
337// Redraws "updated 12s ago" while the pane is open
338let ticker: Timer | undefined
339
340function startTicker($: EngineInterface) {
341 ticker ??= $.clock.every(TICK_MS, () => void update($, tick, n => n + 1))
342}
343
344function stopTicker() {
345 ticker?.cancel()
346 ticker = undefined
347}
348
349async function setVisible($: EngineInterface, value: boolean, focus = false) {
350 await update($, isVisible, () => value)
351 try {
352 await $.store.set('visible', value)
353 } catch {
354 // the toggle still works for this session
355 }
356 if (value) {
357 // open first: the count fills the pane in as it lands
358 await $.ui.open(focus ? { ...PANE_ARGS, focus: true } : PANE_ARGS)
359 startTicker($)
360 void refresh($)
361 } else {
362 stopTicker()
363 await $.ui.close({ id: PANE })
364 }
365}
366
367async function toggleVisible($: EngineInterface) {
368 await setVisible($, !(await read($, isVisible)))
369}
370
371async function setMode($: EngineInterface, value: CtxDetail) {
372 await update($, mode, () => value)
373 await refresh($)
374}
375
376async function toggleOpen($: EngineInterface, key: string) {
377 await update($, opened, list => (list.includes(key) ? list.filter(k => k !== key) : [...list, key]))
378}
379
380type El = ReturnType<EngineInterface['ui']['resolve']>
381
382// The segmented bar: one Text per run, each in its category's hover group
383function Bar({ el, runs }: { el: El; runs: BarRun[] }) {
384 const { Box, Text } = el
385 return (
386 <Box flexDirection="row" flexShrink={0}>
387 {runs.map(r => (
388 <Text
389 color={r.color}
390 {...(r.bg ? { backgroundColor: r.bg } : {})}
391 {...(r.dim ? { dimColor: true } : {})}
392 hover={{ scope: SCOPE + r.scope, inverse: true }}
393 >
394 {r.text}
395 </Text>
396 ))}
397 </Box>
398 )
399}
400
401// "81.9k/1M·" dim, then the percent bold in green, amber or red
402function Fill({ el, s }: { el: El; s: CtxSnapshot }) {
403 const { Box, Text } = el
404 return (
405 <Box flexDirection="row" flexShrink={0}>
406 <Text dimColor>{`${fmtTokens(s.total)}/${fmtTokens(s.max)}·`}</Text>
407 <Text bold color={fillColor(s.percent)}>{`${Math.round(s.percent)}%`}</Text>
408 </Box>
409 )
410}
411
412// The fill per turn: dim history, the latest reading lit in the fill's color
413// The fill per turn: dim history, compactions lit in blue, the latest
414// reading in the fill's color
415function Spark({ el, values, marks, width, percent }: { el: El; values: readonly number[]; marks: readonly boolean[]; width: number; percent: number }) {
416 const { Box, Text } = el
417 const glyphs = [...sparkline(values, width)]
418 const flags = alignFlags(marks, values.length).slice(-glyphs.length)
419 const runs: { text: string; kind: 'past' | 'mark' | 'last' }[] = []
420 glyphs.forEach((g, i) => {
421 const kind = i === glyphs.length - 1 ? 'last' : flags[i] ? 'mark' : 'past'
422 const run = runs[runs.length - 1]
423 if (run && run.kind === kind) run.text += g
424 else runs.push({ text: g, kind })
425 })
426 return (
427 <Box flexDirection="row" flexShrink={0}>
428 {runs.map(r =>
429 r.kind === 'past' ? (
430 <Text color="claude" dimColor>{r.text}</Text>
431 ) : r.kind === 'mark' ? (
432 <Text color="permission" bold>{r.text}</Text>
433 ) : (
434 <Text color={fillColor(percent)} bold>{r.text}</Text>
435 ),
436 )}
437 </Box>
438 )
439}
440
441// Pieces of text, each in its tone: the band's last-turn suffix
442function Pieces({ el, pieces }: { el: El; pieces: Piece[] }) {
443 const { Box, Text } = el
444 return (
445 <Box flexDirection="row" flexShrink={0}>
446 {pieces.map(p =>
447 p.tone === 'plain' ? (
448 <Text>{p.text}</Text>
449 ) : p.tone === 'dim' ? (
450 <Text dimColor>{p.text}</Text>
451 ) : p.tone === 'sub' ? (
452 <Text color="permission">{p.text}</Text>
453 ) : (
454 <Text color={p.tone} bold>{p.text}</Text>
455 ),
456 )}
457 </Box>
458 )
459}
460
461function Swatch({ el, kind, color, scope }: { el: El; kind: string; color: string; scope: string }) {
462 const { Text } = el
463 const hover = { scope: SCOPE + scope, inverse: true }
464 if (kind === 'free') return <Text color="inactive" dimColor hover={hover}>{FREE_GLYPH}</Text>
465 if (kind === 'buffer') return <Text color={color} dimColor hover={hover}>{BUFFER_GLYPH}</Text>
466 return <Text color={color} dimColor={kind === 'deferred'} hover={hover}>■</Text>
467}
468
469// A detail row under an open section: name on the left, figures on the right
470function Item({ el, name, right, truncate = 'truncate-end' }: { el: El; name: string; right: string; truncate?: 'truncate-end' | 'truncate-start' }) {
471 const { Box, Text } = el
472 return (
473 <Box flexDirection="row" width="100%" columnGap={2}>
474 <Box flexGrow={1} flexShrink={1}>
475 <Text dimColor wrap={truncate}>{' ' + name}</Text>
476 </Box>
477 <Text dimColor>{right}</Text>
478 </Box>
479 )
480}
481
482export const register: Register = on => {
483 on('session.start', async ($, e, next) => {
484 try {
485 await $.command.register({
486 name: 'ctx',
487 description: 'Context window details (/ctx opens the pane; /ctx show|hide|toggle|exact|quick|compact [focus]|autokeep [on|off]|cost [on|off])',
488 argumentHint: '[show|hide|toggle|exact|quick|compact [focus]|autokeep [on|off]|cost [on|off]]',
489 immediate: true,
490 })
491 } catch {
492 // the 📊 button still works without the command
493 }
494 await refresh($)
495 try {
496 // the project's log as it stood before this session (a hot reload
497 // runs this again: this session's own line is left out either way)
498 const log = (await $.store.get(await projectKey($))) as CtxProjectLog | undefined
499 const id = await $.session.id()
500 await update($, project, () => (log ? { sessions: log.sessions.filter(x => x.id !== id) } : null))
501 } catch {
502 // no history: every streak starts at this session
503 }
504 try {
505 await update($, autokeep, () => false)
506 if ((await $.store.get('autokeep')) === true) await update($, autokeep, () => true)
507 } catch {
508 // autokeep stays off
509 }
510 try {
511 // the ledger as it stands, and the prices earlier sessions taught
512 await update($, costOn, () => true)
513 if ((await $.store.get('cost')) === false) await update($, costOn, () => false)
514 const saved = (await $.store.get('costSamples')) as CtxSample[] | undefined
515 if (Array.isArray(saved)) await update($, spend, s => (s.samples.length ? s : { ...s, samples: saved }))
516 const usd = (await $.session.usage()).cost?.usd
517 if (usd !== undefined) await update($, spend, s => ledgerAt(s, usd))
518 } catch {
519 // no ledger: the dollar figures stay out
520 }
521 try {
522 // reopen where the person left it; unasked, it waits below 144 columns
523 const saved = await $.store.get('visible')
524 await update($, isVisible, () => saved === true)
525 if (saved === true) {
526 void $.ui.open(PANE_ARGS)
527 startTicker($)
528 }
529 } catch {
530 // start closed
531 }
532 return next(e)
533 })
534
535 // The engine pushes a measurement when the window's fill moves (after each
536 // main-thread turn, a compaction, a /clear): count again then, never on a timer
537 on('session.measure', async ($, e, next) => {
538 const r = await next(e)
539 // the cost ledger, real dollars: the running turn has spent what it grew by
540 const usd = e.cost?.usd
541 if (usd !== undefined) await update($, spend, s => ledgerAt(s, usd))
542 if (e.changed.includes('context')) {
543 // the API's own figure, so switching between exact and quick adds no jumps
544 const tokens = e.context.tokens
545 if (tokens !== undefined) {
546 const past = await read($, history)
547 const prev = past[past.length - 1]
548 if (prev !== undefined && tokens < prev * DROP_RATIO) {
549 await update($, eaters, () => [])
550 await update($, warned, () => 'none')
551 } else if (prev !== undefined) {
552 const chars = turnChars
553 await update($, cptAtom, cpt => calibrate(cpt, chars, tokens - prev))
554 }
555 turnChars = 0
556 await record($, tokens)
557 // warn once per step as auto-compaction nears
558 const w = await warning($)
559 const was = await read($, warned)
560 if (shouldWarn(was, w.level) && w.compactAt) {
561 await update($, warned, () => w.level)
562 $.ui.toast(warnText(w.level, w.tokens, w.compactAt, w.turnsLeft))
563 }
564 }
565 void refresh($).then(() => logProject($))
566 }
567 return r
568 })
569
570 // Every tool call: which server, skill or agent it used, and, in the main
571 // conversation, how much its result added to the window
572 on('tool.call', async ($, e, next) => {
573 const r = await next(e)
574 try {
575 const args = e as unknown as Record<string, unknown>
576 const hit = usageOf(e.tool, args)
577 if (hit) await update($, usage, u => addUsage(u, hit))
578 // the files the session changed, for what a compaction must keep
579 const path = typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : ''
580 const ok = !('isError' in r && r.isError) && !('deny' in r && r.deny)
581 if (path && ok && (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit')) {
582 await update($, edited, list => addRecent(list, path, EDIT_LIMIT))
583 }
584 // a subagent's results stay in its own window
585 const text = 'text' in r && typeof r.text === 'string' ? r.text : ''
586 if (!e.agentId && text.length > 0) {
587 turnChars += text.length
588 if (text.length >= EATER_MIN_CHARS) {
589 const label = toolLabel(e.tool, args)
590 const turn = (await $.session.turns()) + 1
591 await update($, eaters, list => addEater(list, { tool: e.tool, label, chars: text.length, turn }))
592 const tokens = estimateTokens(text.length, await read($, cptAtom))
593 if (tokens >= TOAST_TOKENS) $.ui.toast(`${label} added ~${fmtTokens(tokens)} tokens`)
594 }
595 }
596 } catch {
597 // counting never gets in the way of the call
598 }
599 return r
600 })
601
602 // A main-loop turn begins (subagents raise none): the one before is frozen,
603 // and a clean one is kept, here and for later sessions, to learn prices from
604 on('turn.start', async ($, e, next) => {
605 try {
606 const was = (await read($, spend)).samples
607 const s = await update($, spend, startTurn)
608 // state hands back copies: a new sample shows in the length or the newest one
609 if (s.samples.length !== was.length || s.samples[s.samples.length - 1]?.usd !== was[was.length - 1]?.usd) {
610 await $.store.set('costSamples', s.samples)
611 }
612 } catch {
613 // the turn runs whatever the count does
614 }
615 return next(e)
616 })
617
618 // Every model request, main loop and subagents: its token counts as the API
619 // reported them. Observe only: the response streams through untouched
620 on('turn.step', async function* ($, e, next) {
621 let startedAt = 0
622 try {
623 startedAt = await $.clock.now()
624 } catch {
625 // timing is only evidence about the cache
626 }
627 const r = yield* next(e)
628 try {
629 const u = r.usage
630 if (u) {
631 const endedAt = await $.clock.now()
632 await update($, spend, s => stepDone(s, u, { ...(e.agentId ? { agentId: e.agentId } : {}), startedAt, endedAt }))
633 }
634 } catch {
635 // counting never gets in the way of the response
636 }
637 return r
638 })
639
640 on('turn.complete', async ($, e, next) => {
641 const r = await next(e)
642 try {
643 await update($, spend, s => turnDone(s, e.agentId))
644 } catch {
645 // the answer stands either way
646 }
647 return r
648 })
649
650 // The person's latest requests, for what a compaction must keep
651 on('prompt.submit', async ($, e, next) => {
652 const text = e.text.trim()
653 if (text && !text.startsWith('/')) await update($, asks, list => addRecent(list, clipAsk(text), ASK_LIMIT))
654 return next(e)
655 })
656
657 // Every compaction of the main conversation: with autokeep on, the summary is
658 // told what to keep; once it stands, the drop is marked and toasted
659 on('session.compact', async ($, e, next) => {
660 if (e.agentId) return next(e)
661 // only a compaction that carries its messages is rewritten (the engine's always do)
662 const keep =
663 (await read($, autokeep)) && Array.isArray(e.messages) ? { ...e, instructions: mergeInstructions(e.instructions, await keepText($)) } : e
664 const before = await latestTokens($)
665 const ledger = (await read($, spend)).ledger
666 const r = await next(keep)
667 if (e.trigger !== 'precompute' && r.messages && !selfCompact) void priceCompaction($, r, ledger).then(() => afterCompaction($, before))
668 return r
669 })
670
671 on('command.run', { command: 'ctx' }, async ($, e) => {
672 const [word = '', ...rest] = String(e.args || '').trim().split(/\s+/)
673 const arg = word.toLowerCase()
674 if (arg === 'compact') {
675 await compactNow($, rest.join(' '))
676 return {}
677 }
678 if (arg === 'autokeep') {
679 const v = rest[0]?.toLowerCase()
680 await setAutokeep($, v === 'on' ? true : v === 'off' ? false : !(await read($, autokeep)))
681 return {}
682 }
683 if (arg === 'cost') {
684 const v = rest[0]?.toLowerCase()
685 await setCost($, v === 'on' ? true : v === 'off' ? false : !(await read($, costOn)))
686 return {}
687 }
688 if (arg === 'hide') {
689 await setVisible($, false)
690 return {}
691 }
692 if (arg === 'toggle') {
693 await toggleVisible($)
694 return {}
695 }
696 if (arg === 'exact' || arg === 'full') await update($, mode, () => 'full')
697 if (arg === 'quick' || arg === 'summary') await update($, mode, () => 'summary')
698 await setVisible($, true, true)
699 return {}
700 })
701
702 // a close by the person (Esc, the pane's mark) turns the toggle off too
703 on('ui.close', async ($, e, next) => {
704 const r = await next(e)
705 // an unload (hot reload, exit) must not forget that the pane was wanted
706 if (e.id === PANE && e.origin.kind !== 'unload') {
707 stopTicker()
708 await update($, isVisible, () => false)
709 try {
710 await $.store.set('visible', false)
711 } catch {
712 // nothing to keep
713 }
714 }
715 return r
716 })
717
718 // The band: a small bar and the fill on one line, the 📊 toggle at its right
719 // edge. The toggle opens or closes the pane, which docks to the right like /diff
720 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
721 const below = await next(e)
722 if (e.props.hasSurvey) return below
723 const el = $.ui.resolve(e)
724 const { Box, Button, Text } = el
725 const s = await read($, snap)
726 const show = await read($, isVisible)
727 const old = await read($, stale)
728 const past = await read($, history)
729 const wide = (e.props.bodyColumns ?? 80) >= 70
730 const barWidth = wide ? 16 : 8
731 const step = trend(past).delta
732 const marks = await read($, compacted)
733 const w = await warning($)
734 const hot = w.level === 'imminent' ? 'error' : 'warning'
735 // the warning badge takes the sparkline's place on a band under 100 columns
736 const cols = e.props.bodyColumns ?? 80
737 const roomy = wide && (w.level === 'none' || cols >= 100)
738 // the last turn's dollars, from the ledger; nothing where there is none
739 const sp = await read($, spend)
740 const money = (await read($, costOn)) && sp.ledger !== undefined
741 const lastTurn = money ? shownTurn(sp) : undefined
742 const rates = money ? ratesOf(sp) : undefined
743 return (
744 <Box flexDirection="column">
745 <Box flexDirection="row" justifyContent="space-between" width="100%" columnGap={2}>
746 {s ? (
747 <Box flexDirection="row" columnGap={1} flexShrink={1}>
748 {w.level === 'none' ? <Text dimColor>ctx</Text> : <Text color={hot} bold>ctx</Text>}
749 {Bar({ el, runs: barRuns(s, barWidth) })}
750 {Fill({ el, s })}
751 {roomy && past.length >= 2 && Spark({ el, values: past, marks, width: 10, percent: s.percent })}
752 {roomy && step !== undefined && step !== 0 && <Text dimColor>{fmtDelta(step)}</Text>}
753 {wide &&
754 lastTurn &&
755 Pieces({ el, pieces: bandSuffix(lastTurn, cols, { median: sp.usds.length >= 3 ? median(sp.usds) : undefined, subUsd: subUsd(lastTurn, rates) }) })}
756 {w.level !== 'none' && w.compactAt && (
757 <Text color={hot} bold>
758 {warnBadge(w.tokens, w.compactAt, w.turnsLeft)}
759 </Text>
760 )}
761 {w.level !== 'none' && rates && cols >= 100 && <Text dimColor>{`· ~${fmtUsd(carry(w.tokens, rates))}/request`}</Text>}
762 {old && <Text color="warning">⚠ stale</Text>}
763 </Box>
764 ) : (
765 <Text dimColor>Context window</Text>
766 )}
767 <Button
768 key="ctx-toggle"
769 label={show ? '📊▾' : '📊'}
770 hotkey="x"
771 plain
772 dimColor={!show}
773 onPress={() => toggleVisible($)}
774 />
775 </Box>
776 {below}
777 </Box>
778 )
779 })
780
781 // The footer, for the Desktop app, where the band does not draw; on the
782 // terminal the band already shows the fill
783 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
784 if (e.surface !== 'desktop') return next(e)
785 const s = await read($, snap)
786 if (!s) return next(e)
787 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `ctx ${Math.round(s.percent)}%`] } })
788 })
789
790 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
791 const el = $.ui.resolve(e)
792 const { Box, Button, Text } = el
793 const s = await read($, snap)
794 const open = await read($, opened)
795 const want = await read($, mode)
796 const old = await read($, stale)
797 const past = await read($, history)
798 const eaten = await read($, eaters)
799 const cpt = await read($, cptAtom)
800 const calls = await read($, usage)
801 const log = await read($, project)
802 const marks = await read($, compacted)
803 const keepOn = await read($, autokeep)
804 const w = await warning($)
805 await read($, tick)
806 const now = await $.clock.now()
807 // the body as the surface measured it: the person may drag the dock narrower
808 const width = Math.max(20, e.props.bodyColumns || PANE_ARGS.columns)
809 if (!s) return <Text dimColor>No context data yet. It fills in after the first response.</Text>
810
811 const used = Math.max(1, s.total)
812 const section = (key: string, hotkey: string, title: string, tokens: number, count: string) => {
813 const g = shareBar(tokens, used, GAUGE)
814 return (
815 <Box flexDirection="row" width="100%" columnGap={1}>
816 <Box flexGrow={1} flexShrink={1}>
817 <Button key={'sec-' + key} label={`${open.includes(key) ? '▾' : '▸'} ${title}`} hotkey={hotkey} plain onPress={() => toggleOpen($, key)} />
818 </Box>
819 <Box flexDirection="row" flexShrink={0}>
820 <Text color="claude">{g.fill}</Text>
821 <Text color="inactive" dimColor>{g.rest}</Text>
822 </Box>
823 <Text dimColor>{fmtTokens(tokens).padStart(6)}</Text>
824 <Text bold>{count.padStart(6)}</Text>
825 </Box>
826 )
827 }
828 const marker = compactMarker(s, width)
829 const pace = trend(past, s.compactAt)
830 // the engine's own count of finished turns; the sparkline can't be counted
831 // by eye (it is capped and skips repeats). A failed call just hides it.
832 let turnsDone: number | undefined
833 try {
834 turnsDone = await $.session.turns()
835 } catch {
836 turnsDone = undefined
837 }
838 const turnCount = turnsDone && turnsDone > 0 ? `· ${turnsDone} ${turnsDone === 1 ? 'turn' : 'turns'}` : undefined
839
840 // What this session has paid for and not used: loaded MCP servers it never
841 // called, skills and agent types listed for the model and never invoked
842 // Dollar figures: what the last turn spent (real, from the ledger), and what
843 // the window costs to keep, rewrite and compact (at the learned prices)
844 const sp = await read($, spend)
845 const costShown = await read($, costOn)
846 const money = costShown && sp.ledger !== undefined
847 const rates = money ? ratesOf(sp) : undefined
848 const lastTurn = money ? shownTurn(sp) : undefined
849 // the ledger's total is the denominator for the turn's dollars below it
850 const session = money && sp.ledger !== undefined ? sessionParts(sp.ledger, lastTurn?.usd) : undefined
851 const winTokens = past[past.length - 1] ?? s.total
852 const rpt = perTurn(sp)
853 const after = afterCompact(sp, s)
854 const plan = rates && after !== undefined && winTokens > 2 * after ? compactPlan(winTokens, after, rates, sp.compact?.summary ?? SUMMARY_TOKENS) : undefined
855 const ttl = ttlMs(sp)
856 const idleMs = sp.lastStepAt !== undefined ? now - sp.lastStepAt : 0
857 const cold = money && ttl !== undefined && sp.lastStepAt !== undefined && idleMs >= ttl
858 const muted = (text: string) => <Text color="inactive" dimColor wrap="wrap">{text}</Text>
859
860 const idleServers = s.servers
861 .filter(v => v.tokens > 0 && !calls.mcp[v.key])
862 .map(v => {
863 const run = idleSessions(log ?? undefined, v.key)
864 // this session counts too, with what it has spent so far
865 const cost = rates ? idleCost(v.tokens, [...run, { requests: sp.requests, misses: sp.misses }], rates) : undefined
866 return { ...v, streak: run.length + 1, cost }
867 })
868 const idleSkills = s.skills.filter(k => !calls.skills[k.name])
869 const idleAgents = s.agents.filter(a => !calls.agents[a.name])
870 const sumOf = (xs: { tokens: number }[]) => xs.reduce((a, x) => a + x.tokens, 0)
871 const deadTokens = sumOf(idleServers) + sumOf(idleSkills) + sumOf(idleAgents)
872 const eatenTokens = eaten.reduce((a, x) => a + estimateTokens(x.chars, cpt), 0)
873 const heat = (t: number) => (t >= TOAST_TOKENS ? 'error' : t >= 5000 ? 'warning' : 'inactive')
874 const status = want !== s.detail ? 'counting…' : s.detail === 'full' ? 'exact count' : 'estimated'
875
876 return (
877 <Box flexDirection="column">
878 <Box flexDirection="row" justifyContent="space-between" width="100%" columnGap={2}>
879 <Box flexShrink={1}>
880 <Text bold wrap="truncate-end">{s.model || 'Context window'}</Text>
881 </Box>
882 {Fill({ el, s })}
883 </Box>
884 {w.level !== 'none' && (
885 <Text color={w.level === 'imminent' ? 'error' : 'warning'} bold wrap="wrap">
886 {(w.level === 'imminent' ? '⚠ Auto-compact is close' : '⚠ Auto-compact is coming') + ' · c compacts on your terms'}
887 </Text>
888 )}
889 {cold && (
890 <Box flexDirection="column">
891 <Text color="warning" bold wrap="wrap">{`⚠ cache likely expired · idle ${fmtIdle(idleMs)}`}</Text>
892 {muted(
893 ` next request re-writes ${fmtTokens(winTokens)}` +
894 (rates ? ` ≈ ~${fmtUsd(rewrite(winTokens, rates))} (warm ~${fmtUsd(carry(winTokens, rates))})` : ''),
895 )}
896 {rates && after !== undefined && after < winTokens && muted(` c compact first: re-writes ~${fmtTokens(after)} ≈ ~${fmtUsd(rewrite(after, rates))}`)}
897 </Box>
898 )}
899 {Bar({ el, runs: barRuns(s, width) })}
900 {marker && <Text color="inactive" dimColor>{marker}</Text>}
901 {past.length >= 2 && (
902 <Box flexDirection="column">
903 <Box flexDirection="row" columnGap={1}>
904 <Text color="inactive" dimColor>trend</Text>
905 {Spark({ el, values: past, marks, width: Math.max(8, Math.min(HISTORY_LIMIT, width - 6 - (turnCount ? turnCount.length + 1 : 0))), percent: s.percent })}
906 {turnCount && <Text color="inactive" dimColor>{turnCount}</Text>}
907 </Box>
908 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
909 <Text color="inactive" dimColor>{trendLabel({ delta: pace.delta, perTurn: pace.perTurn })}</Text>
910 {pace.turnsLeft !== undefined && (
911 <Text color={pace.turnsLeft <= 3 ? 'error' : pace.turnsLeft <= 10 ? 'warning' : 'inactive'} dimColor={pace.turnsLeft > 10} bold={pace.turnsLeft <= 10}>
912 {`· ~${pace.turnsLeft} ${pace.turnsLeft === 1 ? 'turn' : 'turns'} to auto-compact`}
913 </Text>
914 )}
915 </Box>
916 </Box>
917 )}
918 {(session || rates || lastTurn) && (
919 <Box flexDirection="column">
920 {session && (
921 // the total is the one figure picked out; siblings, not nesting, because a child can't undo its parent's dim
922 <Text wrap="wrap">
923 <Text color="inactive" dimColor>session </Text>
924 <Text color="success">{session.amount}</Text>
925 <Text color="inactive" dimColor>{session.tail}</Text>
926 </Text>
927 )}
928 {rates && muted(carryLine(winTokens, rates, rpt, pace))}
929 {lastTurn && (
930 <Text dimColor wrap="wrap">
931 {turnLine(lastTurn, lastTurn.subRuns > 0 ? subUsd(lastTurn, rates) : undefined)}
932 </Text>
933 )}
934 {lastTurn && lastTurn.missTokens > 0 && (
935 <Text color="error" wrap="wrap">
936 {missLine(lastTurn, rates)}
937 </Text>
938 )}
939 {plan &&
940 muted(
941 `compact now saves ~${fmtUsd(plan.saving)}/request · ~${fmtUsd(plan.once)} once · pays back in ~${plan.payback} ${plan.payback === 1 ? 'request' : 'requests'}`,
942 )}
943 {plan &&
944 rates &&
945 rpt &&
946 after !== undefined &&
947 pace.turnsLeft !== undefined &&
948 muted(`at auto in ~${pace.turnsLeft} turns · waiting costs ~${fmtUsd(carry(winTokens - after, rates) * rpt * pace.turnsLeft)} more`)}
949 </Box>
950 )}
951 <Text> </Text>
952 {s.rows
953 .filter(r => r.tokens > 0 || r.kind === 'free')
954 .map(r => (
955 <Box flexDirection="row" width="100%" columnGap={1}>
956 {Swatch({ el, kind: r.kind, color: r.color, scope: r.name })}
957 <Box flexGrow={1} flexShrink={1}>
958 <Text wrap="truncate-end" dimColor={r.kind === 'deferred'} hover={{ scope: SCOPE + r.name, bold: true }}>
959 {r.name}
960 </Text>
961 </Box>
962 <Text dimColor>{fmtTokens(r.tokens).padStart(6)}</Text>
963 <Text bold>{fmtShare(r, s.max).padStart(6)}</Text>
964 </Box>
965 ))}
966 <Text> </Text>
967 {section('mcp', 'm', 'MCP tools', s.mcpTokens, String(s.mcpCount))}
968 {s.mcpDeferredCount > 0 && (
969 <Text color="inactive" dimColor wrap="truncate-end">
970 {` + ${fmtTokens(s.mcpDeferredTokens)} in ${s.mcpDeferredCount} deferred, loaded on demand`}
971 </Text>
972 )}
973 {open.includes('mcp') &&
974 s.servers.map(v =>
975 Item({
976 el,
977 name: v.name,
978 right: `${fmtTokens(v.tokens)} ${String(v.count).padStart(3)}` + (v.deferredCount ? ` +${v.deferredCount} deferred` : ''),
979 }),
980 )}
981 {section('memory', 'f', 'Memory files', s.memory.reduce((a, f) => a + f.tokens, 0), String(s.memory.length))}
982 {open.includes('memory') &&
983 s.memory.map((f, i) => (
984 <Box flexDirection="row" width="100%" columnGap={2}>
985 <Box flexGrow={1} flexShrink={1}>
986 <Text>{' '}</Text>
987 <Button key={'mem-' + i} label={`${f.type} ${shortPath(f.path)} ↗`} plain dimColor onPress={() => openFile($, f.path)} />
988 </Box>
989 <Text dimColor>{fmtTokens(f.tokens)}</Text>
990 </Box>
991 ))}
992 {section('skills', 's', 'Skills', s.skillTokens, fmtCount(s.skillIncluded, s.skillCount))}
993 {open.includes('skills') && s.skills.slice(0, SKILL_LIMIT).map(k => Item({ el, name: k.name, right: fmtTokens(k.tokens) }))}
994 {open.includes('skills') && s.skills.length > SKILL_LIMIT && (
995 <Text color="inactive" dimColor>
996 {` +${s.skills.length - SKILL_LIMIT} more, ${fmtTokens(s.skills.slice(SKILL_LIMIT).reduce((a, k) => a + k.tokens, 0))}`}
997 </Text>
998 )}
999 {s.agents.length > 0 && section('agents', 'a', 'Agents', s.agentTokens, String(s.agents.length))}
1000 {open.includes('agents') && s.agents.map(a => Item({ el, name: a.name, right: fmtTokens(a.tokens) }))}
1001 {s.commandCount > 0 && (
1002 <Box flexDirection="row" width="100%" columnGap={1}>
1003 <Box flexGrow={1} flexShrink={1}>
1004 <Text>{' Slash commands'}</Text>
1005 </Box>
1006 <Box flexDirection="row" flexShrink={0}>
1007 <Text color="claude">{shareBar(s.commandTokens, used, GAUGE).fill}</Text>
1008 <Text color="inactive" dimColor>{shareBar(s.commandTokens, used, GAUGE).rest}</Text>
1009 </Box>
1010 <Text dimColor>{fmtTokens(s.commandTokens).padStart(6)}</Text>
1011 <Text bold>{fmtCount(s.commandIncluded, s.commandCount).padStart(6)}</Text>
1012 </Box>
1013 )}
1014 <Text color="inactive" dimColor>{'╌'.repeat(width)}</Text>
1015 {section('eaters', 'h', 'Heaviest results', eatenTokens, String(eaten.length))}
1016 {open.includes('eaters') && eaten.length === 0 && (
1017 <Text color="inactive" dimColor>
1018 {' nothing over ' + fmtTokens(estimateTokens(EATER_MIN_CHARS, cpt)) + ' yet'}
1019 </Text>
1020 )}
1021 {open.includes('eaters') &&
1022 eaten.map(x => {
1023 const t = estimateTokens(x.chars, cpt)
1024 return (
1025 <Box flexDirection="row" width="100%" columnGap={2}>
1026 <Box flexGrow={1} flexShrink={1}>
1027 <Text dimColor wrap="truncate-end">{' ' + x.label}</Text>
1028 </Box>
1029 <Text color="inactive" dimColor>{'t' + x.turn}</Text>
1030 <Text color={heat(t)} dimColor={t < 5000} bold={t >= 5000}>{('~' + fmtTokens(t)).padStart(6)}</Text>
1031 </Box>
1032 )
1033 })}
1034 {section('dead', 'd', 'Unused so far', deadTokens, String(idleServers.length + idleSkills.length + idleAgents.length))}
1035 {open.includes('dead') &&
1036 idleServers.map(v => (
1037 <Box flexDirection="column" width="100%">
1038 <Box flexDirection="row" width="100%" columnGap={2}>
1039 <Box flexGrow={1} flexShrink={1}>
1040 <Text dimColor wrap="truncate-end">{' ' + v.name}</Text>
1041 </Box>
1042 {v.streak >= 2 ? (
1043 <Text color={v.streak >= 5 ? 'warning' : 'inactive'} dimColor={v.streak < 5}>{`idle ${v.streak} sessions`}</Text>
1044 ) : (
1045 <Text color="inactive" dimColor>no calls</Text>
1046 )}
1047 <Text dimColor>{fmtTokens(v.tokens).padStart(6)}</Text>
1048 </Box>
1049 {v.cost && (
1050 <Text color="inactive" dimColor wrap="truncate-end">
1051 {` ~${fmtUsd(v.cost.total)} so far` + (v.cost.n > 1 ? ` · ~${fmtUsd(v.cost.perSession)}/session` : '')}
1052 </Text>
1053 )}
1054 </Box>
1055 ))}
1056 {open.includes('dead') && s.skills.length > 0 &&
1057 Item({ el, name: `skills ${s.skills.length - idleSkills.length} of ${s.skills.length} used`, right: fmtTokens(sumOf(idleSkills)) })}
1058 {open.includes('dead') && s.agents.length > 0 &&
1059 Item({ el, name: `agents ${s.agents.length - idleAgents.length} of ${s.agents.length} used`, right: fmtTokens(sumOf(idleAgents)) })}
1060 {open.includes('dead') && idleServers.some(v => v.streak >= 5) && (
1061 <Text color="inactive" dimColor wrap="wrap">
1062 {' servers idle 5+ sessions here: consider turning them off for this project in /mcp'}
1063 </Text>
1064 )}
1065 <Text> </Text>
1066 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
1067 <Button key="ctx-refresh" label="↻ refresh" hotkey="r" plain onPress={() => refresh($)} />
1068 <Button key="ctx-compact" label="⇣ compact" hotkey="c" plain onPress={() => compactNow($)} />
1069 <Button key="ctx-autokeep" label={keepOn ? '◉ autokeep' : '○ autokeep'} hotkey="k" plain onPress={() => setAutokeep($, !keepOn)} />
1070 <Button key="ctx-cost" label={costShown ? '◉ cost' : '○ cost'} hotkey="p" plain onPress={() => setCost($, !costShown)} />
1071 <Button
1072 key="ctx-exact"
1073 label={want === 'full' ? '◉ exact' : '○ exact'}
1074 hotkey="e"
1075 plain
1076 onPress={() => setMode($, want === 'full' ? 'summary' : 'full')}
1077 />
1078 <Text color="inactive" dimColor>{`${status} · ${ago(now - s.at)}`}</Text>
1079 {old && <Text color="warning">⚠ stale</Text>}
1080 </Box>
1081 {e.props.placement === 'inline' && (
1082 <Text color="inactive" dimColor wrap="wrap">
1083 Shown above the prompt. In the fullscreen layout (110+ columns) this docks beside the transcript.
1084 </Text>
1085 )}
1086 <Text color="inactive" dimColor>{`ctx v${VERSION}`}</Text>
1087 </Box>
1088 )
1089 })
1090}
1091hooks/fmt.ts 203 lines1import type { CtxRow, CtxSnapshot } from '../types'
2
3// 33400 -> "33.4k", 1000000 -> "1M", 584 -> "584"
4export function fmtTokens(n: number): string {
5 if (!Number.isFinite(n)) return '0'
6 if (n < 1000) return String(Math.round(n))
7 if (n < 1_000_000) return trim((n / 1000).toFixed(1)) + 'k'
8 return trim((n / 1_000_000).toFixed(1)) + 'M'
9}
10
11function trim(s: string): string {
12 return s.replace(/\.0$/, '')
13}
14
15// A row's share of the window; deferred schemas sit outside it
16export function fmtShare(row: CtxRow, max: number): string {
17 if (row.kind === 'deferred' || !max) return '—'
18 return ((row.tokens / max) * 100).toFixed(1) + '%'
19}
20
21// "12/40" when the listing left some out, "40" when it holds them all
22export function fmtCount(included: number, total: number): string {
23 return included < total ? `${included}/${total}` : String(total)
24}
25
26export function headline(snap: CtxSnapshot): string {
27 return `${fmtTokens(snap.total)} / ${fmtTokens(snap.max)} (${Math.round(snap.percent)}%)`
28}
29
30// Theme keys, so the colors follow the person's light or dark theme
31export function fillColor(percent: number): 'success' | 'warning' | 'error' {
32 return percent < 50 ? 'success' : percent < 80 ? 'warning' : 'error'
33}
34
35export function ago(ms: number): string {
36 const s = Math.max(0, Math.round(ms / 1000))
37 if (s < 10) return 'just now'
38 if (s < 60) return `${s}s ago`
39 if (s < 3600) return `${Math.floor(s / 60)}m ago`
40 return `${Math.floor(s / 3600)}h ago`
41}
42
43export function shortPath(p: string): string {
44 const parts = p.replace(/\\/g, '/').split('/')
45 return parts.length > 2 ? '…/' + parts.slice(-2).join('/') : p
46}
47
48// Left-aligned partial blocks, indexed by eighths filled (1 to 7)
49const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
50// The least a category holding anything gets, so it never vanishes
51const MIN_EIGHTHS = 2
52export const FREE_GLYPH = '·'
53export const BUFFER_GLYPH = '░'
54
55type Seg = { name: string; color: string; style: 'solid' | 'free' | 'buffer'; start: number; end: number }
56
57// The window laid out in eighths of a cell, as /context orders it: the used
58// rows, the free space, the compaction buffer against the right edge
59export function barSegments(snap: CtxSnapshot, width: number): Seg[] {
60 const total = Math.max(1, width) * 8
61 const max = snap.max || 1
62 const eighths = (t: number) => Math.round((t / max) * total)
63 const segs: Seg[] = []
64 let pos = 0
65 for (const r of snap.rows) {
66 if (r.kind !== 'used' || r.tokens <= 0 || pos >= total) continue
67 const end = Math.min(total, pos + Math.max(MIN_EIGHTHS, eighths(r.tokens)))
68 segs.push({ name: r.name, color: r.color, style: 'solid', start: pos, end })
69 pos = end
70 }
71 const buffer = snap.rows.find(r => r.kind === 'buffer' && r.tokens > 0)
72 const bufStart = buffer ? Math.max(pos, total - eighths(buffer.tokens)) : total
73 if (bufStart > pos) {
74 const free = snap.rows.find(r => r.kind === 'free')
75 segs.push({ name: free?.name ?? 'Free space', color: 'inactive', style: 'free', start: pos, end: bufStart })
76 }
77 if (buffer && total > bufStart) segs.push({ name: buffer.name, color: buffer.color, style: 'buffer', start: bufStart, end: total })
78 return segs
79}
80
81/** A run of same-styled cells; `scope` is the category it draws, for hover. */
82export type BarRun = { text: string; color: string; bg?: string; dim?: boolean; scope: string }
83
84// The bar as runs of cells, `width` cells wide. A cell where one used row
85// ends draws that row's partial block over the next row's color, so the bar
86// is smooth to an eighth of a cell rather than a whole one
87export function barRuns(snap: CtxSnapshot, width: number): BarRun[] {
88 const segs = barSegments(snap, width)
89 // never empty: whatever the rows hold, free space or a used row covers the width
90 const segAt = (e: number) => segs.find(s => s.start <= e && e < s.end) ?? segs[segs.length - 1]!
91 const runs: BarRun[] = []
92 for (let c = 0; c < Math.max(1, width); c++) {
93 const cell = cellOf(segAt(c * 8), segAt(c * 8 + 7), c * 8)
94 const last = runs[runs.length - 1]
95 if (last && last.color === cell.color && last.bg === cell.bg && last.dim === cell.dim && last.scope === cell.scope) {
96 last.text += cell.text
97 } else {
98 runs.push(cell)
99 }
100 }
101 return runs
102}
103
104function cellOf(a: Seg, b: Seg, start: number): BarRun {
105 if (a.style === 'solid') {
106 if (a === b) return { text: '█', color: a.color, scope: a.name }
107 return {
108 text: EIGHTHS[Math.min(7, a.end - start)] ?? '▏',
109 color: a.color,
110 bg: b.style === 'solid' ? b.color : undefined,
111 scope: a.name,
112 }
113 }
114 const s = a.end - start >= 4 ? a : b
115 return s.style === 'free'
116 ? { text: FREE_GLYPH, color: 'inactive', dim: true, scope: s.name }
117 : { text: BUFFER_GLYPH, color: s.color, dim: true, scope: s.name }
118}
119
120// The line beneath the bar: a caret under the auto-compact point, labelled on
121// whichever side has room, the label shortened (or left out) on a narrow bar
122export function compactMarker(snap: CtxSnapshot, width: number): string | undefined {
123 if (!snap.compactAt || !snap.max || width < 1) return undefined
124 const col = Math.min(width - 1, Math.max(0, Math.floor((snap.compactAt / snap.max) * width)))
125 for (const label of [`auto-compact ${fmtTokens(snap.compactAt)}`, fmtTokens(snap.compactAt)]) {
126 if (col + 2 + label.length <= width) return ' '.repeat(col) + '▲ ' + label
127 if (col >= label.length + 1) return ' '.repeat(col - label.length - 1) + label + ' ▲'
128 }
129 return ' '.repeat(col) + '▲'
130}
131
132// A small gauge for a section header: `part` of `whole` in `width` cells
133export function shareBar(part: number, whole: number, width: number): { fill: string; rest: string } {
134 const n = whole > 0 ? Math.min(width * 8, Math.round((part / whole) * width * 8)) : 0
135 const full = Math.floor(n / 8)
136 const fill = '█'.repeat(full) + EIGHTHS[n % 8]
137 return { fill, rest: FREE_GLYPH.repeat(width - full - (n % 8 ? 1 : 0)) }
138}
139
140const SPARK = '▁▂▃▄▅▆▇█'
141
142// The last `width` readings as one glyph each, scaled between their own low
143// and high so a slow climb still shows; a flat run sits mid-height
144export function sparkline(values: readonly number[], width: number): string {
145 const v = values.slice(-Math.max(1, width))
146 if (v.length === 0) return ''
147 const lo = Math.min(...v)
148 const hi = Math.max(...v)
149 if (hi === lo) return (SPARK[3] ?? '▄').repeat(v.length)
150 return v.map(x => SPARK[Math.round(((x - lo) / (hi - lo)) * 7)] ?? '▄').join('')
151}
152
153export type Trend = {
154 /** What the last turn added (negative after a compaction or /clear). */
155 delta?: number
156 /** Average growth per turn since the last drop, over the last few turns. */
157 perTurn?: number
158 /** Turns until auto-compaction at that pace. */
159 turnsLeft?: number
160}
161
162const PACE_TURNS = 6
163
164// How the window grows: the last step, the pace since the last drop (a
165// compaction resets the pace rather than dragging it negative), and how many
166// turns that pace leaves before auto-compaction
167export function trend(values: readonly number[], compactAt?: number): Trend {
168 const n = values.length
169 if (n < 2) return {}
170 const last = values[n - 1]!
171 const delta = last - values[n - 2]!
172 let from = n - 1
173 while (from > 0 && n - 1 - from < PACE_TURNS && values[from - 1]! <= values[from]!) from--
174 if (from === n - 1) return { delta }
175 const perTurn = (last - values[from]!) / (n - 1 - from)
176 if (perTurn <= 0) return { delta, perTurn: 0 }
177 const turnsLeft = compactAt && compactAt > last ? Math.ceil((compactAt - last) / perTurn) : undefined
178 return { delta, perTurn, ...(turnsLeft !== undefined ? { turnsLeft } : {}) }
179}
180
181// "+3.2k", "−41k", "±0"
182export function fmtDelta(n: number): string {
183 if (n === 0) return '±0'
184 return (n > 0 ? '+' : '−') + fmtTokens(Math.abs(n))
185}
186
187// The pane's trend caption, as long as the facts allow
188export function trendLabel(t: Trend): string {
189 const parts: string[] = []
190 if (t.delta !== undefined) parts.push(`${fmtDelta(t.delta)} last turn`)
191 if (t.perTurn) parts.push(`~${fmtTokens(t.perTurn)}/turn`)
192 if (t.turnsLeft !== undefined) parts.push(`~${t.turnsLeft} ${t.turnsLeft === 1 ? 'turn' : 'turns'} to auto-compact`)
193 return parts.join(' · ')
194}
195
196// The host command that opens `path` with its default app: Start-Process on
197// Windows (as Explorer would), `open` on macOS, `xdg-open` elsewhere. Never
198// $EDITOR: a terminal editor has no terminal to draw in from here
199export function openerFor(path: string, os: 'windows' | 'mac' | 'linux'): string[] {
200 if (os === 'windows') return ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Start-Process -FilePath '${path.replace(/'/g, "''")}'`]
201 return [os === 'mac' ? 'open' : 'xdg-open', path]
202}
203hooks/cost.ts 307 lines1import type { CtxCounts, CtxSample, CtxSpend, CtxTurn } from '../types'
2import { fmtTokens } from './fmt'
3
4/** One request's token counts in the API's spelling (ModelUsage, TurnUsage). */
5export type ApiUsage = {
6 input_tokens: number
7 output_tokens: number
8 cache_read_input_tokens: number
9 cache_creation_input_tokens: number
10 model?: string
11}
12
13/** A model's prices as learned from the ledger, US dollars per million tokens. */
14export type Rates = {
15 /** Uncached input; output is priced at OUTPUT_RATIO times it. */
16 inOut: number
17 read: number
18 write: number
19}
20
21// Output costs five times input on every current model, so the two share one price to learn
22export const OUTPUT_RATIO = 5
23const M = 1_000_000
24const TURNS_KEPT = 48
25const SAMPLES_KEPT = 24
26// A request that wrote this many tokens fresh, more than half its prompt, missed the cache
27const MISS_MIN = 20_000
28// An idle gap shorter than this says nothing about how long the cache lives
29const GAP_MIN = 4 * 60_000
30const FIVE_MIN = 5 * 60_000
31const HOUR = 60 * 60_000
32// The fit stands only if every turn it kept is priced this closely
33const FIT_TOLERANCE = 0.05
34
35export const ZERO: CtxCounts = { input: 0, output: 0, read: 0, write: 0 }
36export const EMPTY_SPEND: CtxSpend = { usds: [], samples: [], requests: 0, misses: 0, turns: 0, maxHitGap: 0 }
37
38export function countsOf(u: ApiUsage): CtxCounts {
39 return { input: u.input_tokens, output: u.output_tokens, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens }
40}
41
42function add(a: CtxCounts, b: CtxCounts): CtxCounts {
43 return { input: a.input + b.input, output: a.output + b.output, read: a.read + b.read, write: a.write + b.write }
44}
45
46export function isMiss(c: CtxCounts): boolean {
47 return c.write >= MISS_MIN && c.write > 0.5 * (c.input + c.read + c.write)
48}
49
50// The share of the prompt the cache served
51export function hitRate(c: CtxCounts): number | undefined {
52 const all = c.input + c.read + c.write
53 return all > 0 ? c.read / all : undefined
54}
55
56function newTurn(base: number): CtxTurn {
57 return { base, usd: 0, requests: 0, main: ZERO, model: '', mixed: false, sub: ZERO, subRuns: 0, missTokens: 0, cold: false, compacted: false, done: false }
58}
59
60// A turn the ledger priced from the main model's own counts alone: nothing
61// else (a subagent, a compaction, a second model) is mixed into its dollars
62function isClean(t: CtxTurn): boolean {
63 return t.done && !t.mixed && !t.compacted && t.subRuns === 0 && t.sub.input + t.sub.read + t.sub.write === 0 && t.requests > 0 && t.usd > 0 && !!t.model
64}
65
66// A new main-loop turn: the one before is frozen, its dollars kept, and,
67// when clean, kept as a sample of what the model charges
68export function startTurn(s: CtxSpend): CtxSpend {
69 const ended = s.cur
70 const next: CtxSpend = { ...s, cur: newTurn(s.ledger ?? 0) }
71 if (!ended || (ended.requests === 0 && ended.usd === 0)) return next
72 next.last = ended
73 if (ended.done) next.usds = [...s.usds, ended.usd].slice(-TURNS_KEPT)
74 if (isClean(ended)) {
75 const m = ended.main
76 const sample: CtxSample = { model: ended.model, inOut: (m.input + OUTPUT_RATIO * m.output) / M, read: m.read / M, write: m.write / M, usd: ended.usd }
77 next.samples = [...s.samples, sample].slice(-SAMPLES_KEPT)
78 }
79 return next
80}
81
82// One model request finished: main-loop requests count toward the turn and
83// the cache's record; a subagent's only toward the turn's subagent share. The
84// session's first request, and the first after a compaction, write the prompt
85// afresh by nature: a miss that costs (and is counted) but is never shown
86export function stepDone(s: CtxSpend, u: ApiUsage, at: { agentId?: string; startedAt: number; endedAt: number }): CtxSpend {
87 const cur = s.cur ?? newTurn(s.ledger ?? 0)
88 const c = countsOf(u)
89 if (at.agentId) return { ...s, cur: { ...cur, sub: add(cur.sub, c) } }
90 const miss = isMiss(c)
91 const cold = s.lastStepAt === undefined
92 const model = u.model ?? cur.model
93 const next: CtxSpend = {
94 ...s,
95 cur: {
96 ...cur,
97 requests: cur.requests + 1,
98 main: add(cur.main, c),
99 model,
100 mixed: cur.mixed || (!!cur.model && !!u.model && u.model !== cur.model),
101 missTokens: miss && !cold ? Math.max(cur.missTokens, c.write) : cur.missTokens,
102 cold: cur.cold || cold,
103 },
104 requests: s.requests + 1,
105 misses: s.misses + (miss ? 1 : 0),
106 lastStepAt: at.endedAt,
107 }
108 if (s.lastStepAt !== undefined) {
109 const gap = at.startedAt - s.lastStepAt
110 if (miss && gap >= GAP_MIN) next.minMissGap = Math.min(s.minMissGap ?? Infinity, gap)
111 else if (!miss && c.read > 0) next.maxHitGap = Math.max(s.maxHitGap, gap)
112 }
113 return next
114}
115
116// A turn ended: the main loop's closes the turn; a subagent's run is counted in it
117export function turnDone(s: CtxSpend, agentId?: string): CtxSpend {
118 if (!s.cur) return s
119 if (agentId) return { ...s, cur: { ...s.cur, subRuns: s.cur.subRuns + 1 } }
120 return { ...s, cur: { ...s.cur, done: true }, turns: s.turns + 1 }
121}
122
123// The ledger moved: the running turn has spent what it grew by since the turn began
124export function ledgerAt(s: CtxSpend, usd: number): CtxSpend {
125 return { ...s, ledger: usd, ...(s.cur ? { cur: { ...s.cur, usd: Math.max(0, usd - s.cur.base) } } : {}) }
126}
127
128// A compaction ran: its turn teaches no prices, and its size and cost are kept.
129// The prefix changed, so the next request writes afresh whatever the cache's
130// lifetime: it starts the idle record over rather than teaching it
131export function compacted(s: CtxSpend, c: { after?: number; usd?: number; summary?: number }): CtxSpend {
132 const { lastStepAt: _, ...rest } = s
133 return { ...rest, compact: c, ...(s.cur ? { cur: { ...s.cur, compacted: true } } : {}) }
134}
135
136// The latest turn that finished
137export function shownTurn(s: CtxSpend): CtxTurn | undefined {
138 return s.cur?.done ? s.cur : s.last?.done ? s.last : undefined
139}
140
141export function median(xs: readonly number[]): number | undefined {
142 if (xs.length === 0) return undefined
143 const v = [...xs].sort((a, b) => a - b)
144 const mid = v.length >> 1
145 return v.length % 2 ? v[mid]! : (v[mid - 1]! + v[mid]!) / 2
146}
147
148// The three prices that best explain the samples, by least squares
149function solve(pts: readonly CtxSample[]): Rates | undefined {
150 const xs = pts.map(p => [p.inOut, p.read, p.write] as const)
151 const a = [0, 1, 2].map(i => [0, 1, 2].map(j => xs.reduce((sum, x) => sum + x[i]! * x[j]!, 0)))
152 const b = [0, 1, 2].map(i => xs.reduce((sum, x, k) => sum + x[i]! * pts[k]!.usd, 0))
153 const det = (m: number[][]) =>
154 m[0]![0]! * (m[1]![1]! * m[2]![2]! - m[1]![2]! * m[2]![1]!) -
155 m[0]![1]! * (m[1]![0]! * m[2]![2]! - m[1]![2]! * m[2]![0]!) +
156 m[0]![2]! * (m[1]![0]! * m[2]![1]! - m[1]![1]! * m[2]![0]!)
157 const d = det(a)
158 // turns too alike to tell the prices apart
159 if (!(Math.abs(d) > 1e-6 * a[0]![0]! * a[1]![1]! * a[2]![2]!)) return undefined
160 const col = (k: number) => det(a.map((row, i) => row.map((v, j) => (j === k ? b[i]! : v))))
161 return { inOut: col(0) / d, read: col(1) / d, write: col(2) / d }
162}
163
164const predict = (r: Rates, p: CtxSample) => p.inOut * r.inOut + p.read * r.read + p.write * r.write
165
166// What the model charges, learned from the ledger's own turns. The ledger is
167// exact, so clean turns fit exactly; a turn the fit misses (a tool's own model
168// call billed in it) is dropped, worst first. None until three turns agree,
169// and none that breaks how prices relate (a read is cheaper than input, a
170// write dearer)
171export function fitRates(samples: readonly CtxSample[], model: string, n = 12): Rates | undefined {
172 let pts = samples.filter(x => x.model === model).slice(-n)
173 const keep = Math.max(3, Math.ceil(pts.length / 2))
174 while (pts.length >= keep) {
175 const r = solve(pts)
176 if (!r) return undefined
177 const errs = pts.map(p => Math.abs(predict(r, p) - p.usd) / p.usd)
178 const worst = errs.indexOf(Math.max(...errs))
179 if (errs[worst]! <= FIT_TOLERANCE) return r.read > 0 && r.read < r.inOut && r.write >= r.inOut ? r : undefined
180 pts = pts.filter((_, i) => i !== worst)
181 }
182 return undefined
183}
184
185export function price(r: Rates, c: CtxCounts): number {
186 return ((c.input + OUTPUT_RATIO * c.output) * r.inOut + c.read * r.read + c.write * r.write) / M
187}
188
189// What the subagents cost: the turn's dollars less what its main-loop counts explain
190export function subUsd(t: CtxTurn, r: Rates | undefined): number | undefined {
191 return r ? Math.max(0, t.usd - price(r, t.main)) : undefined
192}
193
194// Re-reading `tokens` from the cache once
195export const carry = (tokens: number, r: Rates) => (tokens * r.read) / M
196// Writing `tokens` into the cache afresh
197export const rewrite = (tokens: number, r: Rates) => (tokens * r.write) / M
198
199// Re-reading a window that grows `perTurn` a turn, `rpt` times a turn, for `turns` turns
200export function untilCompact(tokens: number, perTurn: number, turns: number, rpt: number, r: Rates): number {
201 return ((turns * rpt * r.read) / M) * (tokens + (perTurn * (turns + 1)) / 2)
202}
203
204// Compacting now: what each later request saves, what the compaction costs
205// once (the summarizer reading the window and writing the summary, then the
206// smaller window cached afresh), and after how many requests it pays back
207export function compactPlan(tokens: number, after: number, r: Rates, summary: number) {
208 const saving = ((tokens - after) * r.read) / M
209 if (saving <= 0) return undefined
210 const once = (tokens * r.read + summary * OUTPUT_RATIO * r.inOut + after * r.write) / M
211 return { saving, once, payback: Math.ceil(once / saving) }
212}
213
214// A server's schemas, re-read on every request and rewritten on every miss, over the sessions that logged both
215export function idleCost(tokens: number, sessions: readonly { requests?: number; misses?: number }[], r: Rates) {
216 const known = sessions.filter(x => x.requests !== undefined)
217 if (known.length === 0) return undefined
218 const total = known.reduce((sum, x) => sum + (tokens * ((x.requests ?? 0) * r.read + (x.misses ?? 0) * r.write)) / M, 0)
219 return { total, perSession: total / known.length, n: known.length }
220}
221
222// How long the cache lives, as this session has seen it: a hit after more
223// than five idle minutes means the hour-long cache; a miss after a long gap,
224// the five-minute one. Nothing seen, nothing said
225export function ttlMs(s: CtxSpend): number | undefined {
226 if (s.maxHitGap > FIVE_MIN * 1.1) return HOUR
227 if (s.minMissGap !== undefined) return s.minMissGap < HOUR ? FIVE_MIN : HOUR
228 return undefined
229}
230
231// "$0.007", "$1.84", "$12.3", "$140": a small window's per-request cost needs the third decimal
232export function fmtUsd(n: number): string {
233 if (!Number.isFinite(n) || n <= 0) return '$0'
234 if (n < 0.001) return '<$0.001'
235 if (n < 0.01) return '$' + n.toFixed(3)
236 if (n < 10) return '$' + n.toFixed(2)
237 if (n < 100) return '$' + n.toFixed(1)
238 return '$' + Math.round(n).toLocaleString('en-US')
239}
240
241// "7m", "1h 4m"
242export function fmtIdle(ms: number): string {
243 const m = Math.floor(ms / 60_000)
244 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
245}
246
247export type Piece = { text: string; tone: 'plain' | 'dim' | 'sub' | 'warning' | 'error' }
248
249// The band's last-turn suffix: the dollars always, then each piece only when
250// it says something, dropped from the right as the band narrows
251export function bandSuffix(t: CtxTurn, cols: number, o: { median?: number; subUsd?: number }): Piece[] {
252 const hot = o.median !== undefined && o.median > 0 && t.usd > 3 * o.median
253 const pieces: Piece[] = [{ text: '+' + fmtUsd(t.usd), tone: hot ? 'error' : 'plain' }]
254 if (cols >= 120) {
255 pieces.push({ text: `·${t.requests}r`, tone: 'dim' })
256 if (t.subRuns > 0) pieces.push({ text: '·⑂' + (o.subUsd !== undefined ? '~' + fmtUsd(o.subUsd) : ''), tone: 'sub' })
257 }
258 // a cold start or a compaction writes the prompt afresh by nature: nothing out of the ordinary
259 const hit = t.cold || t.compacted ? undefined : hitRate(t.main)
260 if (hit !== undefined && hit < 0.9 && cols >= 100) {
261 const pct = Math.round(hit * 100)
262 pieces.push({ text: cols >= 120 ? `·●${pct}% hit` : `·●${pct}%`, tone: hit < 0.5 ? 'error' : 'warning' })
263 }
264 return pieces
265}
266
267// "last turn +$1.84 (6 requests · 1 subagent ~$0.31 · cache 98% hit)"
268export function turnLine(t: CtxTurn, sub: number | undefined): string {
269 const parts = [`${t.requests} ${t.requests === 1 ? 'request' : 'requests'}`]
270 if (t.subRuns > 0) parts.push(`${t.subRuns} ${t.subRuns === 1 ? 'subagent' : 'subagents'}` + (sub !== undefined ? ` ~${fmtUsd(sub)}` : ''))
271 const hit = hitRate(t.main)
272 if (hit !== undefined) parts.push(`cache ${Math.round(hit * 100)}% hit`)
273 return `last turn +${fmtUsd(t.usd)} (${parts.join(' · ')})`
274}
275
276// "session $14.20 · last turn was 13% of it": the total is the denominator for
277// the turn's dollars, not a billing figure
278export function sessionLine(total: number, last: number | undefined): string | undefined {
279 const p = sessionParts(total, last)
280 return p && `session ${p.amount}${p.tail}`
281}
282
283// The same line in two parts, so the pane can pick the dollars out
284export function sessionParts(total: number, last: number | undefined): { amount: string; tail: string } | undefined {
285 if (!Number.isFinite(total) || total <= 0) return undefined
286 let tail = ''
287 if (last !== undefined && last > 0) {
288 const share = Math.min(100, Math.round((last / total) * 100))
289 tail = ` · last turn was ${share < 1 ? '<1' : share}% of it`
290 }
291 return { amount: fmtUsd(total), tail }
292}
293
294// "carrying ~$0.08/request · ~$0.49/turn · ~$58.5 until auto-compact"
295export function carryLine(tokens: number, r: Rates, rpt: number | undefined, pace: { perTurn?: number; turnsLeft?: number }): string {
296 const per = carry(tokens, r)
297 const parts = [`carrying ~${fmtUsd(per)}/request`]
298 if (rpt) parts.push(`~${fmtUsd(per * rpt)}/turn`)
299 if (rpt && pace.turnsLeft !== undefined) parts.push(`~${fmtUsd(untilCompact(tokens, pace.perTurn ?? 0, pace.turnsLeft, rpt, r))} until auto-compact`)
300 return parts.join(' · ')
301}
302
303// "● cache miss: a request wrote 412k fresh · ~$3.30"
304export function missLine(t: CtxTurn, r: Rates | undefined): string {
305 return `● cache miss: a request wrote ${fmtTokens(t.missTokens)} fresh` + (r ? ` · ~${fmtUsd(rewrite(t.missTokens, r))}` : '')
306}
307hooks/compact.ts 73 lines1import type { CtxWarn } from '../types'
2import { fmtTokens } from './fmt'
3
4/** Marks instructions this mod wrote, so a compaction never gets them twice. */
5export const KEEP_MARK = '[ctx keep]'
6
7// Keeps the newest `limit` distinct items, newest last
8export function addRecent(list: readonly string[], item: string, limit: number): string[] {
9 return [...list.filter(x => x !== item), item].slice(-limit)
10}
11
12// A prompt as the summary should remember it: one line, at most `n` characters
13export function clipAsk(text: string, n = 160): string {
14 const one = text.replace(/\s+/g, ' ').trim()
15 return one.length > n ? one.slice(0, n - 1) + '…' : one
16}
17
18// What a compaction's summary should keep, from what the session did: the
19// focus the person typed, the files being changed, the latest requests
20export function keepInstructions(input: { focus?: string; edited: readonly string[]; asks: readonly string[] }): string {
21 const lines = [`${KEEP_MARK} Besides the usual summary, preserve:`]
22 const focus = input.focus?.trim()
23 if (focus) lines.push(`- The focus going forward: ${focus}`)
24 if (input.edited.length) lines.push(`- The files being worked on, with what was changed in each and why: ${input.edited.join(', ')}`)
25 if (input.asks.length) {
26 lines.push('- The latest requests, newest last, and where each one stands:')
27 input.asks.forEach((a, i) => lines.push(` ${i + 1}. "${a}"`))
28 }
29 if (lines.length === 1) lines.push('- The task in progress and the next step.')
30 return lines.join('\n')
31}
32
33// Adds the mod's instructions after any the person or the engine gave
34export function mergeInstructions(existing: string | undefined, keep: string): string {
35 const given = existing?.trim()
36 if (given?.includes(KEEP_MARK)) return given
37 return given ? `${given}\n\n${keep}` : keep
38}
39
40// The pace counts only once this much of the threshold is filled: early on, one
41// heavy turn sets the pace alone and would warn at a tenth full
42const PACE_FLOOR = { near: 0.4, imminent: 0.6 }
43
44// How close auto-compaction is: by the turns left at the session's pace once
45// the window is well on its way, or by how much of the threshold is filled
46export function warnLevel(tokens: number, compactAt: number | undefined, turnsLeft: number | undefined): CtxWarn {
47 if (!compactAt || compactAt <= 0) return 'none'
48 const filled = tokens / compactAt
49 const within = (turns: number, floor: number) => turnsLeft !== undefined && turnsLeft <= turns && filled >= floor
50 if (filled >= 0.95 || within(3, PACE_FLOOR.imminent)) return 'imminent'
51 if (filled >= 0.85 || within(10, PACE_FLOOR.near)) return 'near'
52 return 'none'
53}
54
55const RANK: Record<CtxWarn, number> = { none: 0, near: 1, imminent: 2 }
56
57// A toast only as the level climbs: each step warns once, and a fall (a
58// compaction) re-arms them
59export function shouldWarn(was: CtxWarn, now: CtxWarn): boolean {
60 return RANK[now] > RANK[was]
61}
62
63export function warnText(level: CtxWarn, tokens: number, compactAt: number, turnsLeft: number | undefined): string {
64 const left = turnsLeft !== undefined ? `~${turnsLeft} ${turnsLeft === 1 ? 'turn' : 'turns'} left` : `${Math.round((tokens / compactAt) * 100)}% of the way`
65 const head = level === 'imminent' ? 'Auto-compact is close' : 'Auto-compact is coming'
66 return `${head}: ${left} (${fmtTokens(tokens)} / ${fmtTokens(compactAt)}). /ctx compact keeps what matters.`
67}
68
69// The band's badge: the turns left when the pace gives them, else the fill
70export function warnBadge(tokens: number, compactAt: number, turnsLeft: number | undefined): string {
71 return turnsLeft !== undefined ? `⚠ ~${turnsLeft} ${turnsLeft === 1 ? 'turn' : 'turns'}` : `⚠ ${Math.round((tokens / compactAt) * 100)}% to compact`
72}
73hooks/usage.ts 118 lines1import type { CtxEater, CtxProjectLog, CtxUsage } from '../types'
2import { shortPath } from './fmt'
3
4// What one tool call was about, in a few words: the file, the command, the
5// pattern; the tool's name alone when nothing says more
6export function toolLabel(tool: string, args: Record<string, unknown>): string {
7 const str = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
8 const clip = (s: string, n: number) => {
9 const one = s.replace(/\s+/g, ' ').trim()
10 return one.length > n ? one.slice(0, n - 1) + '…' : one
11 }
12 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
13 if (mcp) return `${mcp[1]}: ${mcp[2]}`
14 switch (tool) {
15 case 'Read': {
16 const range = typeof args.offset === 'number' ? ` @${args.offset}` : ''
17 return `Read ${shortPath(str('file_path'))}${range}`
18 }
19 case 'Edit':
20 case 'Write':
21 case 'NotebookEdit':
22 return `${tool} ${shortPath(str('file_path') || str('notebook_path'))}`
23 case 'Bash':
24 case 'PowerShell':
25 return `${tool} ${clip(str('command'), 32)}`
26 case 'Grep':
27 case 'Glob':
28 return `${tool} "${clip(str('pattern'), 24)}"`
29 case 'WebFetch':
30 return `WebFetch ${clip(str('url').replace(/^https?:\/\//, ''), 32)}`
31 case 'WebSearch':
32 return `WebSearch "${clip(str('query'), 24)}"`
33 case 'Agent':
34 case 'Task':
35 return `Agent ${clip(str('description') || str('subagent_type'), 28)}`
36 case 'Skill':
37 return `Skill ${str('skill')}`
38 default:
39 return tool
40 }
41}
42
43/** Characters per token before the session has taught us better. */
44export const DEFAULT_CPT = 4
45
46export function estimateTokens(chars: number, cpt: number): number {
47 return Math.round(chars / (cpt > 0 ? cpt : DEFAULT_CPT))
48}
49
50// Learns the session's characters per token from a turn whose growth was mostly
51// tool results: the turn's result characters over what the window grew by. A
52// turn of mostly talk, or a small one, teaches nothing; one sample moves the
53// figure a third of the way, inside the range real text falls in
54export function calibrate(cpt: number, chars: number, grew: number): number {
55 if (grew < 2000 || chars / DEFAULT_CPT < grew * 0.5) return cpt
56 const sample = chars / grew
57 return Math.min(6, Math.max(2.5, cpt * 0.7 + sample * 0.3))
58}
59
60// Keeps the `limit` heaviest results, heaviest first
61export function addEater(list: readonly CtxEater[], eater: CtxEater, limit = 8): CtxEater[] {
62 return [...list, eater].sort((a, b) => b.chars - a.chars).slice(0, limit)
63}
64
65/** What a call used, for the dead-weight count: an MCP server, a skill, an agent type. */
66export type UsageHit = { kind: keyof CtxUsage; name: string }
67
68export function usageOf(tool: string, args: Record<string, unknown>): UsageHit | undefined {
69 const mcp = /^mcp__(.+?)__/.exec(tool)
70 if (mcp) return { kind: 'mcp', name: mcp[1]! }
71 if (tool === 'Skill' && typeof args.skill === 'string') return { kind: 'skills', name: args.skill.replace(/^\//, '') }
72 if (tool === 'Agent' || tool === 'Task') {
73 return { kind: 'agents', name: typeof args.subagent_type === 'string' && args.subagent_type ? args.subagent_type : 'general-purpose' }
74 }
75 return undefined
76}
77
78export function addUsage(u: CtxUsage, hit: UsageHit): CtxUsage {
79 const bucket = u[hit.kind]
80 return { ...u, [hit.kind]: { ...bucket, [hit.name]: (bucket[hit.name] ?? 0) + 1 } }
81}
82
83export const EMPTY_USAGE: CtxUsage = { mcp: {}, skills: {}, agents: {} }
84
85const LOG_SESSIONS = 30
86
87type LoggedSession = CtxProjectLog['sessions'][number]
88
89// Writes this session's line into the project's log: the MCP servers it had
90// loaded (with their tokens), the ones it called, and its requests and cache
91// misses, which price what an idle server cost; the newest session last
92export function logSession(
93 log: CtxProjectLog | undefined,
94 id: string,
95 loaded: Record<string, number>,
96 used: readonly string[],
97 spent?: { requests: number; misses: number },
98): CtxProjectLog {
99 const sessions = (log?.sessions ?? []).filter(s => s.id !== id)
100 sessions.push({ id, loaded, used: [...used], ...(spent ? { requests: spent.requests, misses: spent.misses } : {}) })
101 return { sessions: sessions.slice(-LOG_SESSIONS) }
102}
103
104// The sessions in a row, newest back, that had the server loaded and never
105// called it; a session without it loaded neither counts nor breaks the run
106export function idleSessions(log: CtxProjectLog | undefined, server: string): LoggedSession[] {
107 const run: LoggedSession[] = []
108 for (const s of [...(log?.sessions ?? [])].reverse()) {
109 if (s.used.includes(server)) break
110 if (s.loaded[server] !== undefined) run.push(s)
111 }
112 return run
113}
114
115export function idleStreak(log: CtxProjectLog | undefined, server: string): number {
116 return idleSessions(log, server).length
117}
118hooks/version.ts 5 lines1// The host gives a hooks module no way to read its own manifest (not through
2// "node:fs", not as a JSON import), so the pane's footer carries a copy.
3// Bump it with plugin.json on every release.
4export const VERSION = '0.6.2'
5types/index.d.ts 169 lines1export type CtxRow = {
2 name: string
3 tokens: number
4 color: string
5 kind: 'used' | 'free' | 'buffer' | 'deferred'
6}
7
8/** One MCP server: its schemas in the window, and those loaded on demand. */
9export type CtxServer = {
10 name: string
11 /** The server as its tools' wire names spell it (`mcp__<key>__tool`). */
12 key: string
13 tokens: number
14 count: number
15 deferredTokens: number
16 deferredCount: number
17}
18export type CtxMemoryFile = { path: string; type: string; tokens: number }
19export type CtxSkill = { name: string; tokens: number }
20export type CtxAgent = { name: string; tokens: number }
21
22/** One heavy tool result in the main conversation. */
23export type CtxEater = { tool: string; label: string; chars: number; turn: number }
24
25/** Calls this session, by MCP server key, skill name and agent type. */
26export type CtxUsage = {
27 mcp: Record<string, number>
28 skills: Record<string, number>
29 agents: Record<string, number>
30}
31
32/**
33 * A project's recent sessions: the MCP servers each had loaded, and the ones it called.
34 * `requests` and `misses` (from 0.6.0) price each server's idle overhead; older lines lack them.
35 */
36export type CtxProjectLog = {
37 sessions: { id: string; loaded: Record<string, number>; used: string[]; requests?: number; misses?: number }[]
38}
39
40/** Token counts as the API reports them, summed. */
41export type CtxCounts = { input: number; output: number; read: number; write: number }
42
43/** One main-loop turn's spend: the ledger's growth and the requests behind it. */
44export type CtxTurn = {
45 /** The session's cost ledger, in US dollars, when the turn began. */
46 base: number
47 /** What the ledger grew by since: real dollars, subagents included. */
48 usd: number
49 /** Main-loop model requests in the turn. */
50 requests: number
51 main: CtxCounts
52 /** The API model id of the main loop's last response. */
53 model: string
54 /** More than one model answered the main loop. */
55 mixed: boolean
56 /** Subagents' requests, summed. */
57 sub: CtxCounts
58 subRuns: number
59 /** The largest cache write of a main-loop request that missed the cache. */
60 missTokens: number
61 /**
62 * The turn wrote its prompt afresh as a matter of course: the session's
63 * first request, or the first after a compaction. No miss is shown for it.
64 */
65 cold: boolean
66 /** A compaction ran during the turn. */
67 compacted: boolean
68 /** The turn's `turn.complete` came. */
69 done: boolean
70}
71
72/** One clean turn, for learning the model's prices: tokens in millions, and the dollars they cost. */
73export type CtxSample = { model: string; inOut: number; read: number; write: number; usd: number }
74
75/** What the session has spent, turn by turn, and what that says about its cache. */
76export type CtxSpend = {
77 /** The cost ledger's latest figure; absent where the host keeps none. */
78 ledger?: number
79 /** The turn running, or the last one until the next begins. */
80 cur?: CtxTurn
81 /** The turn before `cur`. */
82 last?: CtxTurn
83 /** Finished turns' dollars, oldest first. */
84 usds: number[]
85 samples: CtxSample[]
86 /** Main-loop requests and cache misses this session, and finished turns. */
87 requests: number
88 misses: number
89 turns: number
90 /** When the main loop's last request ended. */
91 lastStepAt?: number
92 /** The longest idle gap the cache survived, and the shortest (of 4+ minutes) it did not. */
93 maxHitGap: number
94 minMissGap?: number
95 /** The last compaction: its size after, and what it cost. */
96 compact?: { after?: number; usd?: number; summary?: number }
97}
98
99/** How close auto-compaction is. */
100export type CtxWarn = 'none' | 'near' | 'imminent'
101
102/** How a breakdown is counted: estimated locally, or with the token-count API. */
103export type CtxDetail = 'summary' | 'full'
104
105/** A slim copy of /context's breakdown: no 200-square grid, just what is drawn. */
106export type CtxSnapshot = {
107 rows: CtxRow[]
108 total: number
109 max: number
110 percent: number
111 detail: CtxDetail
112 at: number
113 model: string
114 /** Tokens at which auto-compaction runs; absent when it is off. */
115 compactAt?: number
116 mcpTokens: number
117 mcpCount: number
118 mcpDeferredTokens: number
119 mcpDeferredCount: number
120 servers: CtxServer[]
121 memory: CtxMemoryFile[]
122 skillCount: number
123 skillIncluded: number
124 skillTokens: number
125 /** Every skill listing, largest first; the pane shows the top few. */
126 skills: CtxSkill[]
127 agentTokens: number
128 agents: CtxAgent[]
129 commandCount: number
130 commandIncluded: number
131 commandTokens: number
132}
133
134declare module 'claude-code' {
135 interface PluginState {
136 ctx: {
137 snap: CtxSnapshot | null
138 isVisible: boolean
139 opened: string[]
140 mode: CtxDetail
141 stale: boolean
142 tick: number
143 /** The window's tokens after each turn, oldest first, as the API reported them. */
144 history: number[]
145 /** The heaviest tool results of the main conversation, heaviest first. */
146 eaters: CtxEater[]
147 /** Characters per token, learned from this session's turns. */
148 cpt: number
149 usage: CtxUsage
150 /** This project's log as it stood when the session started. */
151 project: CtxProjectLog | null
152 /** Per `history` reading: whether a compaction came just before it. */
153 compacted: boolean[]
154 /** Files the session changed, newest last. */
155 edited: string[]
156 /** The person's latest prompts, newest last, clipped. */
157 asks: string[]
158 /** Whether every compaction gets the mod's keep instructions. */
159 autokeep: boolean
160 /** The highest warning given since the last compaction. */
161 warned: CtxWarn
162 /** What the session spent, per turn, from the engine's cost ledger. */
163 spend: CtxSpend
164 /** Whether dollar figures are shown. */
165 costOn: boolean
166 }
167 }
168}
169