SLOPSHOPPER

limits

Claude Code mod: your 5h/7d quota, context window, prompt-cache time left and session cost on one line above the prompt, with per-subagent cost on hover

newbandguardpromptprocessagents
★ 1v0.8.0MITupdated 2026-10-12VaitaR/claude-code-limits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 5h ━━━━━━ 31% ctx ━━━━━━ 49% cache ○ cold $0.42 ❯ fix the failing auth test and add an audit … ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ━━━━━━ 31% ctx ━━━━━━ 49% cache ○ cold $0.42 ❯ fix the failing auth test and add an audit …
README

limits

License: MIT Claude Code 2.1.287+

Know how much room you have left before the next prompt. A Claude Code mod that puts your 5h and 7d quota, context, prompt-cache time and session cost on one line above the prompt, with the subagents still running and what you last asked. It works in the terminal and the desktop app; hover a segment for details.

<img src="docs/media/demo.gif" alt="A Claude Code session in the terminal. A prompt asks for an Explore subagent; the line above the prompt shows the cache as live during the turn, then the running Explore agent, whose hover reads 1 subagent running. The last prompt has a row under the line. Hovering 5h, 7d, ctx, cache and the cost in turn shows each one's details on the row below, under the segment, the cost split into main thread and subagent" width="760">

<a href="#install">Install</a> · <a href="#what-you-see">What you see</a> · <a href="#configure">Configure</a> · <a href="#make-it-yours">Make it yours</a> · <a href="#what-it-can-reach">What it can reach</a>

Install

Inside Claude Code (2.1.287 or later):

/plugin marketplace add VaitaR/claude-code-limits
/plugin install limits@claude-code-limits
/reload-plugins
git clone https://github.com/VaitaR/claude-code-limits
claude --plugin-dir ./claude-code-limits

Mods are an early-access Claude Code feature and their API can change between releases.

What you see

Terminal
<img src="docs/media/band.png" alt="The band in a terminal: 5h and 7d quota gauges with their resets, the context gauge, the cache dot with minutes left and the session cost, with the last prompt on a row of its own" width="760">
Desktop app
<img src="docs/media/desktop.png" alt="The band in the Claude desktop app above the prompt box: 5h 22% resetting in 2h22m, 7d 49% resetting in 4d19h, context 61% in amber after two compactions, cache live for 59 minutes, $19.51" width="760">

Hovering a segment shows its details: the desktop app lists them as rows, a terminal joins them into one line in a blank row the band keeps under itself, below the segment, or across the segment's row when the band has no row to spare.

SegmentShowsHover, for example
5h 7dhow much of each rate-limit window your plan reports is used, and after ↻ the time to its reset5h · 21% used, 79% left · resets 18:07
ctxcontext against the window Claude Code compacts at; ⟲3 after three compactionsctx · 31k of 258k tokens · 3 compactions
cache● live while a turn runs, then ● 59m, the minutes the prompt cache has left (seconds in its last minute), and ○ cold once it has nonecache · warm until 16:31 (1h TTL)
$0.20the session's cost at API prices, subagents includedsession $0.20 at API prices, subagents included, then the last turn's cost; with a costCommand, rows for the main thread and the subagents
◐ Explorethe subagents running now: the type of one, ◐ 3 agents for several1 subagent running, then per agent its type and task, and a row with how long it has run, its model, its last request's context, its tool calls so far and the latest one: 1m59s · Sonnet 5.5 · 156k ctx · 48 tools · Bash(Find RPC clients)
❯ fix the flaky…your last prompt, in the room the line has left and cut with …; with less than 40 cells left, on a row of its own when there is room for onelast prompt · sent 16:18, then the prompt in full

Quiet until it matters. While all is well the gauges, cache and cost stay in plain text colours. Amber and red mark what needs attention:

AmberRed
5h 7d70% used90% used
ctx60%, and the ⟲N of compactions80%
cacheunder 5 minutes leftexpired: the next prompt rewrites the cache

Model, branch, effort and the like are left to the rest of the app. On a narrow window the line moves whole segments to a second row rather than cutting one; only the last prompt is cut to fit.

Configure

In /config, or under pluginConfigs["limits"].options in settings.json:

OptionDefaultMeaning
cacheTtl1hthe main loop's prompt-cache lifetime: 1h with extended caching, 5m otherwise
palettethemetheme follows your Claude Code theme (light, dark, colour-blind); pastel uses fixed colours tuned for dark terminals
costCommandemptya command that prints the session's cost split as JSON; it is split on spaces into arguments, without quoting

The command gets the session id as its last argument and prints one JSON object:

{ "total_usd": 5.85, "main_usd": 5.75, "unpriced_calls": 0,
  "agents": [{ "agent_type": "Explore", "description": "map the repo", "usd": 0.1 }] }

Only total_usd is required; a missing amount counts as zero. The hover lists agents in the order given, so print the costliest first: it names the first six and sums the rest into one row, and a non-zero unpriced_calls adds a row of its own. For the object above it reads:

session $5.85 at API prices
$5.75 main
$0.10 Explore · map the repo
+$0.42 last turn

The command runs at session start and at the end of each main turn and each subagent, one run at a time, with a 15 s timeout. A non-zero exit, a timeout or output without total_usd keeps the last split. The customize-limits skill explains how to price a transcript.

Make it yours

The plugin ships a skill, customize-limits, that knows where each threshold, colour, gauge and segment lives, how to add one and test it, and the traps of drawing in a terminal. Ask Claude in plain words:

make the context gauge turn amber at 50%

hide the cost segment, or the last prompt while I share my screen

add a segment with the current git branch

wire a costCommand for my sessions

What it can reach

It only watches. No hook denies or rewrites a prompt, a tool call or a subagent; the one drawing hook fills the line above the prompt.

It readsThrough
context, cost and rate-limit readingssession.measure, $.session.usage()
when the main loop's requests are sent and answered; a subagent's model and token counts per requestturn.step
a subagent's tool calls: the tool and the argument that names its target (a Bash call's description or command, a file's name, a search pattern)tool.call
the text of your own prompts: typed, from the desktop app or Remote Control, never a notification's or another plugin'sprompt.submit
compactionssession.compact
subagent spawns and ends: type, description, modelagent.spawn, turn.complete
the tail of this session's transcript, at session start while no request or prompt has been seen yet, to start the cache clock and show the last prompt Claude Code recordedsh -c tail -c 400000 ~/.claude/projects/*/<session id>.jsonl
your costCommand's output, if you set one$.process.run

It makes no network requests and calls no model; your costCommand runs as you wrote it. State lives in session atoms and ends with the session. claude plugin validate . prints exactly what it hooks and calls.

What is inferred, not measured

  • The cache clock starts when the main loop's last answered request was sent and runs for cacheTtl. The engine reports no TTL, so a wrong cacheTtl gives a wrong clock. A compaction sets the cache to cold.
  • Quotas are account-wide. Parallel sessions spend the same windows, and the band shows the last reading this session received. A reading without a window keeps the previous one until it resets.
  • Cost is the engine's API-price figure. On a subscription it is what the session would cost at API prices, not a bill.

Develop

claude --plugin-dir .            # load it; edits hot-reload
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .       # after the first load, which writes .claude-plugin/types/
FileHolds
hooks/register.tsxthe event hooks and the band
hooks/core.tsconfig, formatting and readings as pure functions
hooks/countdown.tsxa surface module: the reset and cache countdowns tick on the surface's own clock
types/index.d.tsthe state contract
skills/customize-limits/the skill for tuning and extending the band

See CONTRIBUTING.md. Changes are listed in CHANGELOG.md.

License

MIT. Started as a fork of claude-flightdeck by Stephen Casella.

Source 4 files
hooks/register.tsx 531 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { LastPrompt, Running, Usage } from '../types'
5import {
6  DEFAULT_USAGE,
7  PALETTES,
8  activityOf,
9  agentLines,
10  contextOf,
11  contextTokens,
12  fmtAt,
13  fmtCacheLeft,
14  fmtLeft,
15  fmtUsd,
16  gaugeCells,
17  kTokens,
18  lastPromptOf,
19  lastRequestAt,
20  limitLabel,
21  limitsOf,
22  listOf,
23  mergeLimits,
24  normalize,
25  parseConfig,
26  parseCostSplit,
27  plural,
28  promptLine,
29  quotaLevel,
30  shorten,
31  splitLines,
32} from './core'
33import type { Config } from './core'
34
35// ---------------------------------------------------------------- state
36
37const usage = atom({ plugin: 'limits', key: 'usage' } as const, DEFAULT_USAGE)
38const cacheAt = atom({ plugin: 'limits', key: 'cacheAt' } as const, 0)
39const running = atom({ plugin: 'limits', key: 'running' } as const, [])
40const costAtStart = atom({ plugin: 'limits', key: 'costAtStart' } as const, null)
41const lastTurnUsd = atom({ plugin: 'limits', key: 'lastTurnUsd' } as const, null)
42const split = atom({ plugin: 'limits', key: 'split' } as const, null)
43const lastPrompt = atom({ plugin: 'limits', key: 'lastPrompt' } as const, null)
44
45// Every read goes through these, so a value saved under an older shape still reads.
46async function getUsage($: EngineInterface): Promise<Usage> {
47  return normalize(DEFAULT_USAGE, await read($, usage))
48}
49async function getRunning($: EngineInterface): Promise<Running[]> {
50  return listOf<Running>(await read($, running))
51}
52/** Changes one running agent; an id not in the list (spawned before a reload) changes nothing. */
53async function touch($: EngineInterface, id: string, f: (a: Running) => Running) {
54  await update($, running, list => listOf<Running>(list).map(a => (a.id === id ? f(a) : a)))
55}
56async function getLastPrompt($: EngineInterface): Promise<LastPrompt | null> {
57  const p = (await read($, lastPrompt)) as Partial<LastPrompt> | null
58  return p && typeof p.text === 'string' && p.text ? { text: p.text, at: typeof p.at === 'number' ? p.at : null } : null
59}
60const PROMPT_MIN = 40
61const PROMPT_FLOOR = 12
62const numOr = <T,>(v: unknown, d: T): number | T => (typeof v === 'number' && Number.isFinite(v) ? v : d)
63
64/**
65 * A module loaded mid-session (a reload, a resume, a later install) has seen no request and no prompt:
66 * the cache clock starts from the transcript's last main-loop request, and the last prompt is the one
67 * Claude Code recorded there. The transcript is found by session id, so no project-folder naming is
68 * assumed; only its tail is read.
69 */
70async function seedFromTranscript($: EngineInterface) {
71  const needCache = numOr(await read($, cacheAt), 0) <= 0
72  const needPrompt = (await getLastPrompt($)) === null
73  if (!needCache && !needPrompt) return
74  const id = await $.session.id().catch(() => null)
75  if (!id || !/^[\w-]+$/.test(id)) return
76  const out = await $.process
77    .run(['sh', '-c', 'tail -c 400000 "$HOME"/.claude/projects/*/"$1".jsonl 2>/dev/null', 'sh', id], { timeoutMs: 5000 })
78    .then(r => r.stdout)
79    .catch(() => '')
80  const at = needCache ? lastRequestAt(out) : null
81  if (at) await update($, cacheAt, x => (numOr(x, 0) > 0 ? x : at))
82  const text = needPrompt ? lastPromptOf(out) : null
83  if (text) await update($, lastPrompt, x => x ?? { text, at: null })
84}
85
86/**
87 * The window Claude Code compacts against: `autoCompactWindow` from settings, or the model's own limit.
88 * A `summary` breakdown is estimated locally and sends no request.
89 */
90async function readCompactWindow($: EngineInterface) {
91  const u = await $.session.usage({ breakdown: 'summary', columns: 40 }).catch(() => null)
92  const raw = u?.context.breakdown?.rawMaxTokens
93  if (raw) await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), compactWindow: raw }))
94}
95
96/**
97 * One run at a time: a turn end and a subagent's end often land together. A request during a run
98 * queues one more, since the running one may have read the transcript before that end was written.
99 */
100let splitRunning = false
101let splitAgain = false
102
103/** The session's cost by transcript, from the person's `costCommand`; a failure keeps the last split. */
104async function refreshSplit($: EngineInterface, cfg: Config) {
105  if (cfg.costCommand.length === 0) return
106  if (splitRunning) {
107    splitAgain = true
108    return
109  }
110  splitRunning = true
111  try {
112    do {
113      splitAgain = false
114      const sid = await $.session.id().catch(() => null)
115      if (!sid) return
116      const r = await $.process.run([...cfg.costCommand, sid], { timeoutMs: 15_000 }).catch(() => null)
117      const s = r && r.exitCode === 0 ? parseCostSplit(r.stdout, await $.clock.now()) : null
118      if (s) await update($, split, () => s)
119    } while (splitAgain)
120  } finally {
121    splitRunning = false
122  }
123}
124
125/** The session's cost read fresh, not from the last measurement: the last turn's cost subtracts two of these. */
126async function costNow($: EngineInterface): Promise<number | null> {
127  const u = await $.session.usage().catch(() => null)
128  return u?.cost?.usd ?? null
129}
130
131async function resetAll($: EngineInterface) {
132  await update($, cacheAt, () => 0)
133  await update($, running, () => [])
134  await update($, costAtStart, () => null)
135  await update($, lastTurnUsd, () => null)
136  await update($, split, () => null)
137  await update($, lastPrompt, () => null)
138  // The context gauge waits for the next measurement rather than showing the pre-clear fill.
139  await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null, compactions: 0 }))
140}
141
142// ---------------------------------------------------------------- hooks
143
144export const register: Register = (on, options) => {
145  const cfg = parseConfig(options)
146  const C = PALETTES[cfg.palette]
147
148  on('session.start', async ($, e, next) => {
149    // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
150    const u = await $.session.usage().catch(() => null)
151    if (u) {
152      const at = await $.clock.now()
153      await update($, usage, x => ({
154        ...normalize(DEFAULT_USAGE, x),
155        pct: u.context.percent ?? null,
156        tokens: u.context.tokens ?? null,
157        window: u.context.window,
158        costUsd: u.cost?.usd ?? null,
159        limits: mergeLimits(normalize(DEFAULT_USAGE, x).limits, limitsOf(u.rateLimits), at),
160      }))
161    }
162    await Promise.all([seedFromTranscript($), readCompactWindow($)])
163    void refreshSplit($, cfg).catch(() => undefined)
164    return next(e)
165  })
166
167  on('session.end', async ($, e, next) => {
168    if (e.reason === 'clear') await resetAll($)
169    return next(e)
170  })
171
172  // The person's own words: typed, through Remote Control, from an SDK host such as the desktop app, or
173  // sent by a plugin as theirs. A task's notification, a peer session or a schedule is not their prompt.
174  on('prompt.submit', async ($, e, next) => {
175    const done = await next(e)
176    const o = e.origin
177    const own = o.kind === 'composer' || o.kind === 'bridge' || o.kind === 'sdk' || (o.kind === 'plugin' && o.asUser === true)
178    const text = promptLine(e.text)
179    // The prompt has entered by now: a failure here must not reach the person as an error on it.
180    if (own && text && !('drop' in done)) {
181      await $.clock
182        .now()
183        .then(at => update($, lastPrompt, () => ({ text, at })))
184        .catch(() => undefined)
185    }
186    return done
187  })
188
189  on('turn.start', async ($, e, next) => {
190    const cost = await costNow($)
191    await update($, costAtStart, () => cost)
192    return next(e)
193  })
194
195  on('turn.step', async function* ($, e, next) {
196    const id = e.agentId
197    const sentAt = id ? 0 : await $.clock.now()
198    const done = yield* next(e)
199    const u = done.usage
200    if (u && id) await touch($, id, a => ({ ...a, model: u.model || a.model, tokens: contextTokens(u) }))
201    else if (u) await update($, cacheAt, () => sentAt)
202    return done
203  })
204
205  // Counted when a call starts, so the activity is what the agent is doing now. A failed write must not
206  // fail the agent's tool.
207  on('tool.call', async ($, e, next) => {
208    const id = e.agentId
209    if (id) {
210      await touch($, id, a => ({ ...a, tools: (a.tools ?? 0) + 1, activity: activityOf(e.tool, e as unknown as Record<string, unknown>) })).catch(
211        () => undefined,
212      )
213    }
214    return next(e)
215  })
216
217  on('session.measure', async ($, e, next) => {
218    const before = await getUsage($)
219    const at = await $.clock.now()
220    await update($, usage, x => ({
221      ...normalize(DEFAULT_USAGE, x),
222      pct: e.context.percent ?? null,
223      tokens: e.context.tokens ?? null,
224      window: e.context.window,
225      costUsd: e.cost?.usd ?? null,
226      limits: mergeLimits(normalize(DEFAULT_USAGE, x).limits, limitsOf(e.rateLimits), at),
227    }))
228    // A model switch changes the window; the compaction window follows it.
229    if (before.window !== e.context.window) await readCompactWindow($)
230    return next(e)
231  })
232
233  on('session.compact', async ($, e, next) => {
234    const done = await next(e)
235    if (!e.agentId && e.trigger !== 'precompute') {
236      await update($, usage, x => {
237        const u = normalize(DEFAULT_USAGE, x)
238        return { ...u, compactions: u.compactions + 1 }
239      })
240      // The compacted context is a new prefix: nothing of it is cached until the next request.
241      await update($, cacheAt, () => 0)
242    }
243    return done
244  })
245
246  on('agent.spawn', async ($, e, next) => {
247    const started = await next(e)
248    if (started.deny !== undefined || !started.agentId) return started
249    const agent: Running = {
250      id: started.agentId,
251      type: e.name ?? e.subagentType,
252      description: e.description || undefined,
253      model: started.model || undefined,
254      startedAt: await $.clock.now(),
255    }
256    await update($, running, list => [...listOf<Running>(list).filter(a => a.id !== agent.id), agent].slice(-24))
257    return started
258  })
259
260  on('turn.complete', async ($, e, next) => {
261    const done = await next(e)
262    const id = e.agentId
263    if (id) {
264      await update($, running, list => listOf<Running>(list).filter(a => a.id !== id))
265      // A subagent's transcript is final once it ends: price it while the main turn may still run.
266      void refreshSplit($, cfg).catch(() => undefined)
267      return done
268    }
269    const [start, cost] = await Promise.all([read($, costAtStart), costNow($)])
270    const from = numOr(start, null)
271    await update($, lastTurnUsd, () => (from !== null && cost !== null ? Math.max(0, cost - from) : null))
272    void refreshSplit($, cfg).catch(() => undefined)
273    return done
274  })
275
276  // ---------------------------------------------------------------- drawing
277
278  // What the rest of the app does not show, on one line above the prompt: quotas, context, cache, cost,
279  // running agents. Calm while all is well: colour marks only what needs attention. Hovering a segment
280  // shows its details.
281  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282    if (e.props.hasSurvey) return next(e)
283    const els = $.ui.resolve(e)
284    const { Box, Text } = els
285    const hasClient = 'Client' in els
286    const [u, agents, cache, now, lastTurn, sp, lp] = await Promise.all([
287      getUsage($),
288      getRunning($),
289      read($, cacheAt),
290      $.clock.now(),
291      read($, lastTurnUsd),
292      read($, split),
293      getLastPrompt($),
294    ])
295    const cx = contextOf(u)
296    const sentAt = numOr(cache, 0)
297    const last = numOr(lastTurn, null)
298    const levelColor = { ok: C.text, warn: C.amber, hot: C.warn }
299    // `plain` is the segment as text, for its width in a terminal's cells.
300    const segments: { key: string; node: RenderChildren; detail: string[]; cells: number }[] = []
301    const add = (key: string, node: RenderChildren, detail: string | string[], plain: string) =>
302      segments.push({ key, node, detail: typeof detail === 'string' ? [detail] : detail, cells: [...plain].length })
303    const gauge = (label: string, pct: number) => `${label} ${'━'.repeat(6)} ${Math.round(pct)}%`
304    // One glyph for both parts keeps every gauge the same width in a proportional font.
305    const meter = (label: string, pct: number, color: string) => {
306      const filled = gaugeCells(pct, 6)
307      return [
308        <Text dimColor>{`${label} `}</Text>,
309        <Text color={color === C.text ? C.dim : color}>{'━'.repeat(filled)}</Text>,
310        <Text color={C.faint}>{'━'.repeat(6 - filled)}</Text>,
311        <Text color={color} bold>{` ${Math.round(pct)}%`}</Text>,
312      ]
313    }
314
315    for (const l of u.limits.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')) {
316      const label = limitLabel(l.kind)
317      add(
318        `quota-${l.kind}`,
319        <Box>
320          {meter(label, l.pct, levelColor[quotaLevel(l.pct)])}
321          {l.resetsAt ? <Text dimColor> ↻</Text> : null}
322          {!l.resetsAt ? null : hasClient ? (
323            <els.Client key={`band-reset-${l.kind}`} module="./countdown.tsx" props={{ until: l.resetsAt, now, kind: 'left', color: C.dim, warn: C.amber, hot: C.warn }} />
324          ) : (
325            <Text dimColor>{fmtLeft(l.resetsAt - now)}</Text>
326          )}
327        </Box>,
328        `${label} · ${Math.round(l.pct)}% used, ${Math.round(100 - l.pct)}% left${l.resetsAt ? ` · resets ${fmtAt(l.resetsAt, now)}` : ''}`,
329        `${gauge(label, l.pct)}${l.resetsAt ? ` ↻${fmtLeft(l.resetsAt - now)}` : ''}`,
330      )
331    }
332    if (cx.pct !== null) {
333      add(
334        'ctx',
335        <Box>
336          {meter('ctx', cx.pct, cx.pct >= 80 ? C.warn : cx.pct >= 60 ? C.amber : C.text)}
337          {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
338        </Box>,
339        `ctx · ${u.tokens !== null ? `${kTokens(u.tokens)} of ${kTokens(cx.limit)} tokens` : `${Math.round(cx.pct)}%`}${u.compactions > 0 ? ` · ${plural(u.compactions, 'compaction')}` : ''}`,
340        `${gauge('ctx', cx.pct)}${u.compactions > 0 ? ` ⟲${u.compactions}` : ''}`,
341      )
342    }
343    const until = sentAt + cfg.cacheTtlMs
344    const ttl = cfg.cacheTtlMs >= 3_600_000 ? '1h' : '5m'
345    add(
346      'cache',
347      <Box>
348        <Text dimColor>cache </Text>
349        {e.props.isWorking ? (
350          <Text color={C.text}>● live</Text>
351        ) : sentAt <= 0 ? (
352          <Text dimColor>○ cold</Text>
353        ) : hasClient ? (
354          <els.Client key="band-cache" module="./countdown.tsx" props={{ until, now, kind: 'cache', color: C.text, warn: C.amber, hot: C.warn }} />
355        ) : (
356          <Text color={until - now <= 0 ? C.warn : until - now < 5 * 60_000 ? C.amber : C.text}>
357            {until - now <= 0 ? '○ cold' : `● ${fmtCacheLeft(until - now)}`}
358          </Text>
359        )}
360      </Box>,
361      // Every request of a running turn re-arms the cache; the countdown starts once the turn ends.
362      e.props.isWorking
363        ? 'cache · live: each request of this turn refreshes it'
364        : sentAt <= 0
365          ? 'cache · cold: the next prompt rewrites it'
366          : until <= now
367            ? `cache · cold since ${fmtAt(until, now)}: the next prompt rewrites it`
368            : `cache · warm until ${fmtAt(until, now)} (${ttl} TTL)`,
369      `cache ${e.props.isWorking ? '● live' : sentAt <= 0 || until <= now ? '○ cold' : `● ${fmtCacheLeft(until - now)}`}`,
370    )
371
372    // The engine prices every response of the session, its subagents' included; on a plan it is the API-price equivalent.
373    if (u.costUsd !== null) {
374      add(
375        'cost',
376        <Text color={C.text}>{fmtUsd(u.costUsd)}</Text>,
377        [
378          `session ${fmtUsd(u.costUsd)} at API prices${sp ? '' : ', subagents included'}`,
379          ...(sp ? splitLines(sp) : []),
380          ...(last !== null ? [`+${fmtUsd(last)} last turn`] : []),
381        ],
382        fmtUsd(u.costUsd),
383      )
384    }
385
386    // A segment like the rest, so a narrow band wraps it whole: squeezed at the right edge it read as `◐…`.
387    if (agents.length > 0) {
388      const label = agents.length === 1 ? `◐ ${agents[0]?.type}` : `◐ ${agents.length} agents`
389      add(
390        'agents',
391        <Text color={C.agent}>{label}</Text>,
392        agentLines(agents, now),
393        label,
394      )
395    }
396
397    const width = Math.max(20, e.props.bodyColumns)
398    const hoverWidth = (rows: string[]) => Math.min(Math.max(...rows.map(l => l.length)) + 2, width)
399    const card = (key: string, rows: string[]) =>
400      rows.map((l, j) => (
401        <Text key={`${key}-${j}`} color={j === 0 ? C.text : C.dim} wrap={key === 'prompt' && j > 0 ? 'wrap' : 'truncate'}>
402          {l}
403        </Text>
404      ))
405
406    // A terminal paints in tree order, so a hover drawn inside its segment lay under the segments after
407    // it, and it clips a hover to the band's own rows, so none can sit above the band. There the band keeps
408    // a blank row under its segments, and a hover is drawn in it under its segment, shifted left to fit:
409    // laid over the band's row it sat flush on the neighbours' text, and revealed as a row in the flow it
410    // made the band jump. With no row to spare under `maxRows` the hover covers its segment's whole row.
411    // Both places come from the band's wrap (its cells are known in a terminal). The segment lights its
412    // hover through a scope.
413    const terminal = e.surface === 'terminal'
414    const placed: { row: number; col: number }[] = []
415    let row = 0
416    let col = 0
417    for (const s of segments) {
418      const room = s.cells + 2
419      if (col > 0 && col + room > width) {
420        row += 1
421        col = 0
422      }
423      placed.push({ row, col })
424      col += room
425    }
426
427    // The last prompt takes what its row has left, cut with `…`; with less than PROMPT_MIN cells left it
428    // starts a row of its own. Its cells are set here, not by its text, so it is placed last. A band
429    // taller than `maxRows` is folded into an `n more` row, hiding every segment: the prompt then stays
430    // in what its row has left, or is left out.
431    const plain = lp ? `❯ ${lp.text}` : ''
432    const full = [...plain].length
433    if (lp && col > 0 && width - col - 2 < Math.min(full, PROMPT_MIN) && row + 2 <= e.props.maxRows) {
434      row += 1
435      col = 0
436    }
437    const cells = Math.min(full, width - col - 2)
438    // PROMPT_FLOOR bounds a cut prompt; a short one that fits whole always shows.
439    if (lp && cells >= Math.min(full, PROMPT_FLOOR)) {
440      segments.push({
441        key: 'prompt',
442        node: (
443          <Box key="band-prompt" width={cells}>
444            <Text color={C.dim} wrap="truncate">
445              {plain}
446            </Text>
447          </Box>
448        ),
449        detail: [lp.at !== null ? `last prompt · sent ${fmtAt(lp.at, now)}` : 'last prompt', shorten(lp.text, 400)],
450        cells,
451      })
452      placed.push({ row, col })
453    }
454    const under = row + 2 <= e.props.maxRows
455
456    return (
457      <Box width={width} flexDirection={terminal ? 'column' : 'row'}>
458        <Box flexWrap="wrap" flexShrink={1}>
459          {/* The gap trails each segment: a wrapped row then starts flush, where a leading `│` read as a stray mark. */}
460          {segments.map(s =>
461            terminal ? (
462              <Box key={s.key} marginRight={2} hover={{ scope: `limits-${s.key}` }}>
463                {s.node}
464              </Box>
465            ) : (
466              <Box key={s.key} marginRight={2}>
467                {s.node}
468                {/* Shown while the pointer is on the segment. Sized to its longest row: unsized, it took the
469                    segment's width and truncated. */}
470                <Box
471                  position="absolute"
472                  top={0}
473                  left={0}
474                  width={hoverWidth(s.detail)}
475                  flexDirection="column"
476                  display="none"
477                  hover={{ display: 'flex' }}
478                  backgroundColor={C.faint}
479                  paddingX={1}
480                >
481                  {card(s.key, s.detail)}
482                </Box>
483              </Box>
484            ),
485          )}
486        </Box>
487        {terminal && under ? (
488          <Box key="hover-row" height={1} width={width}>
489            {segments.map((s, i) => {
490              const line = s.detail.join(' · ')
491              const w = Math.min([...line].length, width)
492              return (
493                <Box
494                  key={`hover-${s.key}`}
495                  position="absolute"
496                  top={0}
497                  left={Math.max(0, Math.min(placed[i]?.col ?? 0, width - w))}
498                  width={w}
499                  display="none"
500                  hover={{ scope: `limits-${s.key}`, display: 'flex' }}
501                >
502                  <Text color={C.dim} wrap="truncate">
503                    {line}
504                  </Text>
505                </Box>
506              )
507            })}
508          </Box>
509        ) : null}
510        {terminal && !under
511          ? segments.map((s, i) => (
512                <Box
513                  key={`hover-${s.key}`}
514                  position="absolute"
515                  top={placed[i]?.row ?? 0}
516                  left={0}
517                  width={width}
518                  display="none"
519                  hover={{ scope: `limits-${s.key}`, display: 'flex' }}
520                  backgroundColor={C.faint}
521                  paddingX={1}
522                >
523                  {card(s.key, [s.detail.join(' · ')])}
524                </Box>
525            ))
526          : null}
527      </Box>
528    )
529  })
530}
531
hooks/core.ts 292 lines
1// Pure data: defaults, config, formatting. Nothing here touches `$`, so every behaviour is testable directly.
2import type { CostSplit, Running, Usage } from '../types'
3
4// ---------------------------------------------------------------- defaults
5
6export const DEFAULT_USAGE: Usage = {
7  pct: null,
8  tokens: null,
9  window: 0,
10  costUsd: null,
11  limits: [],
12  compactions: 0,
13  compactWindow: null,
14}
15
16/** Context against the window Claude Code compacts at, not the model's whole window. */
17export const contextOf = (u: Usage) => {
18  const limit = u.compactWindow ?? u.window
19  const pct = u.tokens !== null && limit > 0 ? (u.tokens / limit) * 100 : u.pct
20  return { limit, pct }
21}
22
23const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
24
25/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
26export const normalize = <T extends object>(def: T, stored: unknown): T =>
27  isObject(stored) ? ({ ...def, ...stored } as T) : def
28
29/** A stored list, or empty when what is stored is not a list. */
30export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
31
32// ---------------------------------------------------------------- config
33
34export type Config = {
35  palette: Palette
36  /** argv the session id is appended to; it prints the JSON the README describes. Empty: no split. */
37  costCommand: string[]
38  /** The prompt cache's lifetime: the engine reports no 5m/1h split, so the person names theirs. */
39  cacheTtlMs: number
40}
41
42/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
43export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
44  const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
45  return {
46    palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
47    costCommand: str('costCommand', '').trim().split(/\s+/).filter(Boolean),
48    cacheTtlMs: str('cacheTtl', '1h') === '5m' ? 300_000 : 3_600_000,
49  }
50}
51
52// ---------------------------------------------------------------- palette
53
54export type Palette = 'theme' | 'pastel'
55export type Colors = Record<'agent' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
56
57/**
58 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
59 * `pastel` is fixed hex tuned for dark terminals.
60 */
61export const PALETTES: Record<Palette, Colors> = {
62  theme: { agent: 'suggestion', amber: 'warning', warn: 'error', dim: 'inactive', faint: 'subtle', text: 'text' },
63  pastel: { agent: '#93c5fd', amber: '#fcd34d', warn: '#fca5a5', dim: '#6b7280', faint: '#3f4654', text: '#e5e7eb' },
64}
65
66// ---------------------------------------------------------------- formatting
67
68export const shorten = (s: string, n: number) => {
69  const one = s.replace(/\s+/g, ' ').trim()
70  return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
71}
72
73export const kTokens = (n: number) =>
74  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
75
76/** `1 call`, `2 calls`. */
77export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
78
79export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
80
81/** Filled cells of a `width`-cell gauge; never none while anything is used: 4% of a quota is not 0%. */
82export const gaugeCells = (pct: number, width: number) => {
83  const round = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
84  return pct > 0 ? Math.max(1, round) : round
85}
86
87/** Colour band of a quota by what is used so far. */
88export const quotaLevel = (pct: number): 'ok' | 'warn' | 'hot' => (pct >= 90 ? 'hot' : pct >= 70 ? 'warn' : 'ok')
89
90/** `2h05m`, `45m`; with days, `2d00h`. */
91export const fmtLeft = (ms: number) => {
92  const m = Math.max(0, Math.floor(ms / 60_000))
93  const d = Math.floor(m / 1440)
94  const h = Math.floor((m % 1440) / 60)
95  if (d > 0) return `${d}d${String(h).padStart(2, '0')}h`
96  return h > 0 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
97}
98
99/** The cache in whole minutes, then seconds in its last minute: the moment to send one more prompt. */
100export const fmtCacheLeft = (ms: number) =>
101  ms <= 0 ? 'cold' : ms < 60_000 ? `${Math.min(59, Math.ceil(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m`
102
103const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
104
105/** A moment as local wall time: `18:40` within a day of `now`, `Thu 14:00` further out. */
106export const fmtAt = (ms: number, now: number) => {
107  const d = new Date(ms)
108  const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
109  return Math.abs(ms - now) < 86_400_000 ? hm : `${DAYS[d.getDay()]} ${hm}`
110}
111
112// ---------------------------------------------------------------- rate limits
113
114type Limit = Usage['limits'][number]
115const LIMIT_ORDER = ['five_hour', 'seven_day']
116
117export const limitsOf = (rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] =>
118  rateLimits.map(r => {
119    const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
120    return { kind: r.kind, pct: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
121  })
122
123/**
124 * A reading without a window keeps the last one until it resets: some responses carry no rate-limit
125 * headers, and an empty reading would otherwise blank the quotas until the next one that does.
126 */
127export const mergeLimits = (prev: readonly Limit[], next: readonly Limit[], now: number): Limit[] => {
128  const seen = new Set(next.map(l => l.kind))
129  const kept = prev.filter(l => !seen.has(l.kind) && (l.resetsAt == null || l.resetsAt > now))
130  const rank = (k: string) => (LIMIT_ORDER.includes(k) ? LIMIT_ORDER.indexOf(k) : LIMIT_ORDER.length)
131  return [...next, ...kept].sort((a, b) => rank(a.kind) - rank(b.kind))
132}
133
134/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
135export const limitLabel = (kind: string) =>
136  kind
137    .replace(/five[_ -]?hours?/i, '5h')
138    .replace(/seven[_ -]?days?/i, '7d')
139    .replace(/[_-]+/g, ' ')
140    .trim()
141
142// ---------------------------------------------------------------- cost split
143
144/** A `costCommand` answer, read leniently: anything without a total is no split. */
145export const parseCostSplit = (stdout: string, at: number): CostSplit | null => {
146  let o: Record<string, unknown>
147  try {
148    o = JSON.parse(stdout)
149  } catch {
150    return null
151  }
152  const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
153  if (typeof o?.total_usd !== 'number') return null
154  return {
155    at,
156    totalUsd: num(o.total_usd),
157    mainUsd: num(o.main_usd),
158    unpriced: num(o.unpriced_calls),
159    agents: listOf<Record<string, unknown>>(o.agents).map(a => ({
160      type: String(a.agent_type ?? 'agent'),
161      description: String(a.description ?? ''),
162      usd: num(a.usd),
163    })),
164  }
165}
166
167/**
168 * One row per part: the main thread, the costliest agents by name, the rest summed, then unpriced calls.
169 * The amount leads: the desktop sets rows in a proportional font, so a row sized by its character count
170 * can still be truncated, and that must cut the description, never the amount.
171 */
172export const splitLines = (s: CostSplit, max = 6) => {
173  const named = s.agents.slice(0, max).map(a => `${fmtUsd(a.usd)} ${a.type}${a.description ? ` · ${shorten(a.description.toLowerCase(), 28)}` : ''}`)
174  const rest = s.agents.slice(max)
175  const more = rest.length > 0 ? [`${fmtUsd(rest.reduce((n, a) => n + a.usd, 0))} +${rest.length} more`] : []
176  const miss = s.unpriced > 0 ? [`${plural(s.unpriced, 'call')} unpriced`] : []
177  return [`${fmtUsd(s.mainUsd)} main`, ...named, ...more, ...miss]
178}
179
180// ---------------------------------------------------------------- running agents
181
182const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
183
184/** `claude-sonnet-5-5` → `Sonnet 5.5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`; an alias capitalised; any other id as given. */
185export const modelLabel = (id: string) => {
186  const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(id)
187  if (m?.[1] && m[2]) return `${cap(m[1].toLowerCase())} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
188  return /^[a-z]+$/.test(id) ? cap(id) : id
189}
190
191/** `45s`, `1m59s`, `1h02m`. */
192export const fmtElapsed = (ms: number) => {
193  const s = Math.max(0, Math.floor(ms / 1000))
194  if (s < 60) return `${s}s`
195  const m = Math.floor(s / 60)
196  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
197}
198
199/** A tool call as Claude Code shows one: `Bash(run the tests)`, `Read(core.ts)`, `Grep(fmtAt)`; the tool alone when no argument names it. */
200export const activityOf = (tool: string, args: Readonly<Record<string, unknown>>) => {
201  const s = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
202  const file = s('file_path') || s('notebook_path')
203  const what =
204    tool === 'Bash'
205      ? s('description') || s('command')
206      : file
207        ? (file.split('/').filter(Boolean).pop() ?? file)
208        : s('description') || s('pattern') || s('query') || s('url') || s('path')
209  return what ? `${tool}(${shorten(what, 40)})` : tool
210}
211
212/** A request's context: what it sent plus what it got back, as the desktop app's agent card counts tokens. */
213export const contextTokens = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) =>
214  u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
215
216/**
217 * Two rows per agent: what it is doing, then how far it got. Each part is left out until its event has
218 * been seen, so an agent spawned before a reload reads as its type alone.
219 */
220export const agentLines = (agents: readonly Running[], now: number, max = 4) => {
221  const rows = agents.slice(0, max).flatMap(a => {
222    const facts = [
223      typeof a.startedAt === 'number' ? fmtElapsed(now - a.startedAt) : '',
224      a.model ? modelLabel(a.model) : '',
225      a.tokens ? `${kTokens(a.tokens)} ctx` : '',
226      a.tools ? plural(a.tools, 'tool') : '',
227      a.activity ?? '',
228    ].filter(Boolean)
229    return [`${a.type}${a.description ? ` · ${shorten(a.description, 48)}` : ''}`, ...(facts.length > 0 ? [facts.join(' · ')] : [])]
230  })
231  return [`${plural(agents.length, 'subagent')} running`, ...rows, ...(agents.length > max ? [`+${agents.length - max} more`] : [])]
232}
233
234// ---------------------------------------------------------------- cache clock
235
236type Row = { type?: string; subtype?: string; isSidechain?: boolean; timestamp?: string; message?: { id?: string; usage?: unknown } }
237
238/**
239 * When the main loop's last answered request was sent, read from the tail of the session's transcript:
240 * the last timed row before the last main-loop response (its own first row when none precedes it). The first
241 * line of a tail is cut and skipped. null when no response is there or a compaction came after it.
242 */
243export const lastRequestAt = (tail: string): number | null => {
244  const rows: Row[] = []
245  for (const line of tail.split('\n').slice(1)) {
246    try {
247      rows.push(JSON.parse(line) as Row)
248    } catch {
249      // a cut or blank line
250    }
251  }
252  const main = rows.filter(r => !r.isSidechain)
253  let last = -1
254  for (let i = main.length - 1; i >= 0; i--) {
255    const r = main[i]
256    if (r?.subtype === 'compact_boundary') return null
257    if (r?.type === 'assistant' && r.message?.usage && r.message.id) {
258      last = i
259      break
260    }
261  }
262  if (last < 0) return null
263  const id = main[last]?.message?.id
264  let first = last
265  while (first > 0 && main[first - 1]?.type === 'assistant' && main[first - 1]?.message?.id === id) first--
266  // The request went out after the last row written before its response: a tool result, a prompt, an attachment.
267  const before = main.slice(0, first).filter(r => r.timestamp).pop()
268  const at = Date.parse((before ?? main[first])?.timestamp ?? '')
269  return Number.isFinite(at) ? at : null
270}
271
272// ---------------------------------------------------------------- last prompt
273
274/** A prompt as one line: line breaks and runs of spaces read as one space; a long paste keeps its head. */
275export const promptLine = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, 1000)
276
277/** The person's last prompt as Claude Code records it in the transcript's `last-prompt` rows; null when the tail has none. */
278export const lastPromptOf = (tail: string): string | null => {
279  const lines = tail.split('\n').slice(1)
280  for (let i = lines.length - 1; i >= 0; i--) {
281    const line = lines[i] ?? ''
282    if (!line.includes('"last-prompt"')) continue
283    try {
284      const r = JSON.parse(line) as { type?: string; lastPrompt?: unknown }
285      if (r.type === 'last-prompt' && typeof r.lastPrompt === 'string') return promptLine(r.lastPrompt) || null
286    } catch {
287      // a cut line
288    }
289  }
290  return null
291}
292
hooks/countdown.tsx 36 lines
1// A countdown ticking on the surface's frame clock, so the cache TTL and reset times move between redraws.
2import type { ClientModule } from 'claude-code'
3import { fmtCacheLeft, fmtLeft } from './core'
4
5type Props = { until: number; now: number; kind: 'cache' | 'left'; color: string; warn: string; hot: string }
6type Ref = { left: number; lastNow: number }
7type State = { ref: Ref }
8
9const text = (props: Props, left: number) => (props.kind === 'left' ? fmtLeft(left) : left <= 0 ? '○ cold' : `● ${fmtCacheLeft(left)}`)
10
11const Countdown: ClientModule<Props, State> = (props, surface) => {
12  const { Text } = surface.elements
13  const ref = surface.state?.ref ?? { left: 0, lastNow: -1 }
14  if (props.now !== ref.lastNow) {
15    ref.lastNow = props.now
16    ref.left = props.until - props.now
17  }
18  if (surface.state === undefined) {
19    surface.setState({ ref })
20    // A tick a minute would drift, so it ticks each second but repaints only when the shown text
21    // changes: each minute, then each second of the cache's last one.
22    surface.every(1000, () => {
23      if (ref.left <= 0) return
24      const was = text(props, ref.left)
25      ref.left -= 1000
26      if (text(props, ref.left) !== was) surface.setState({ ref })
27    })
28  }
29  if (props.kind === 'left') return <Text color={props.color}>{fmtLeft(ref.left)}</Text>
30  // Amber with time left to send one more prompt.
31  const color = ref.left <= 0 ? props.hot : ref.left < 5 * 60_000 ? props.warn : props.color
32  return <Text color={color}>{text(props, ref.left)}</Text>
33}
34
35export default Countdown
36
types/index.d.ts 69 lines
1export type Usage = {
2  pct: number | null
3  tokens: number | null
4  window: number
5  costUsd: number | null
6  /** `resetsAt` in ms; null when the window reported none. */
7  limits: { kind: string; pct: number; resetsAt?: number | null }[]
8  compactions: number
9  /**
10   * The window Claude Code compacts against (`autoCompactWindow` in settings, or the model's own limit),
11   * read from the session's context breakdown; null until read. Context is measured against it.
12   */
13  compactWindow: number | null
14}
15
16/**
17 * A subagent running now, by the id its spawn returned. The rest is optional: a value saved before they
18 * existed still reads, and each is set only once its event was seen.
19 */
20export type Running = {
21  id: string
22  type: string
23  /** The Agent call's few-word `description`. */
24  description?: string
25  /** The model its last request was answered by, else the one its spawn resolved. */
26  model?: string
27  startedAt?: number
28  /** Its last request's context: sent plus answered tokens. */
29  tokens?: number
30  /** Tool calls its loop has started. */
31  tools?: number
32  /** Its latest tool call, as `activityOf` names it. */
33  activity?: string
34}
35
36/** A session's cost split by transcript, as the `costCommand` priced it when `at`. */
37export type CostSplit = {
38  at: number
39  totalUsd: number
40  mainUsd: number
41  /** Calls no price covered: while above 0 the figures are a lower bound. */
42  unpriced: number
43  agents: { type: string; description: string; usd: number }[]
44}
45
46/** The person's last prompt as one line; `at` is when it was sent, null when read back from the transcript. */
47export type LastPrompt = { text: string; at: number | null }
48
49declare module 'claude-code' {
50  interface PluginState {
51    'limits': {
52      usage: Usage
53      /**
54       * When the main loop last sent a request that got a response: each request reads (or writes) the
55       * prompt cache and restarts its TTL. The request's start, not its end, so a long response never
56       * overstates the time left. 0: none since load, /clear or a compaction.
57       */
58      cacheAt: number
59      running: Running[]
60      /** The session's cost when the current main turn started; null when the host reports none. */
61      costAtStart: number | null
62      /** What the last main turn cost; null before one ends or without a cost. */
63      lastTurnUsd: number | null
64      split: CostSplit | null
65      lastPrompt: LastPrompt | null
66    }
67  }
68}
69