SLOPSHOPPER

ctx

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.

newpanebandspinnerguardcommand
v0.6.2MITupdated 2026-10-04afterever/claude-mods/ctx
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx
│ ┃ Context window ✕ › fix the failing auth test and add an audit log call │ ┃ No context data yet. It fills in after the │ ┃ first response. ⏺ 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 │ │ › /ctx │ │ Context window x: 📊▾ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context window x: 📊▾ ⟨Claude Code's own drawing⟩
Pane · Context window
No context data yet. It fills in after the first response.
README

claude-mods

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.

ModWhat it does
ctxContext 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.

Install (each computer)

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

Use ctx

Command or keyDoes
/ctx, or 📊 / x on the bandOpen 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 onoff (k`)Add the same to every compaction, auto-compact included (off by default)
`/ctx cost onoff (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 paneOpen MCP tools, memory files, skills, agents, heaviest results, unused so far
r in the paneCount again

Develop

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.

Layout

.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 contract
Source 7 files
hooks/register.tsx 1091 lines
1import { 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}
1091
hooks/fmt.ts 203 lines
1import 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}
203
hooks/cost.ts 307 lines
1import 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}
307
hooks/compact.ts 73 lines
1import 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}
73
hooks/usage.ts 118 lines
1import 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}
118
hooks/version.ts 5 lines
1// 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'
5
types/index.d.ts 169 lines
1export 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