SLOPSHOPPER

ctx-band

One-line band above the prompt: context fill, growth, cache, rate limits and cost

newbandprompttimer
v0.1.0MITupdated 2026-10-21IanGely/claude-session-mods/ctx-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-band
› 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 ctx ■■■■■□□□□□ 49% 97.4k/200k ▁ ↑0 │ cache warm 1h00m │ 5h 31% ↻NaNdNaNh │ cost ≈$0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx ■■■■■□□□□□ 49% 97.4k/200k ▁ ↑0 │ cache warm 1h00m │ 5h 31% ↻NaNdNaNh │ cost ≈$0.42
README

claude-session-mods

Two Claude Code mods that keep an eye on your session.

session-dashboard

A side pane with two tabs (switch with 1 / 2, or open it with /session-pane):

  • Activity — files changed this session (new vs. modified, +added −removed, edit count, age), subagents while they run (live timer, status; press one to open its transcript), and scheduled tasks (CronCreate, /loop wake-ups) with countdowns.
  • Overhead — context window fill and auto-compact headroom, the window broken down as /context estimates it, per-turn context growth, main-conversation tokens and cache hit rate, 5h / 7d quota with a window-average projection, cost, and per-subagent token use.

ctx-band

One line above the prompt:

ctx ■■■□□□□□□□ 27% 271k/1M  ▁▁▃█▁▇▁ ↑3.4k │ cache warm 38m │ 5h 42% ↻2h34m │ 7d 63% ↻2d7h │ cost ≈$1.84 (+$0.12)

On a narrow terminal the lower-priority segments drop first; the context bar always stays.

Install

At the prompt of a Claude Code terminal session:

/plugin install session-dashboard --marketplace IanGely/claude-session-mods
/plugin install ctx-band --marketplace IanGely/claude-session-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a shell:

claude plugin install session-dashboard --marketplace IanGely/claude-session-mods
claude plugin install ctx-band --marketplace IanGely/claude-session-mods

In the desktop app: click + next to the prompt box → Plugins → Add marketplace with IanGely/claude-session-mods, then Add plugin.

Options

Both mods have a Prompt cache TTL option (1h default, or 5m) in /config. The "cache warm" countdown is estimated from the time of the last request plus this TTL.

Notes

  • Token, cache and growth figures count the requests seen after the mod loaded.
  • Quota shows only on a Claude subscription; the projection assumes the window's average rate continues.
  • Cost is an API-price reference, not a billing receipt.

License

MIT

Source 2 files
hooks/register.tsx 249 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionUsage } from 'claude-code'
3
4import type { Limit, Meter } from '../types'
5
6const meter = atom({ plugin: 'ctx-band', key: 'meter' } as const, null)
7const growth = atom({ plugin: 'ctx-band', key: 'growth' } as const, [])
8const lastRequestAt = atom({ plugin: 'ctx-band', key: 'lastRequestAt' } as const, 0)
9const lastTurnUsd = atom({ plugin: 'ctx-band', key: 'lastTurnUsd' } as const, null)
10
11const GROWTH_KEPT = 10
12const BAR_CELLS = 10
13const SPARKS = '▁▂▃▄▅▆▇█'
14
15// ---------- pure helpers ----------
16
17type UsageLike = Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>
18
19const toMeter = (u: UsageLike): Meter => ({
20  tokens: u.context.tokens,
21  window: u.context.window,
22  percent: u.context.percent,
23  limits: u.rateLimits.map(
24    (r): Limit => ({
25      kind: r.kind,
26      percent: r.percentUsed,
27      resetsAt: r.resetsAt === undefined ? undefined : Date.parse(r.resetsAt),
28    }),
29  ),
30  usd: u.cost?.usd,
31})
32
33const compact = (n: number) => {
34  const abs = Math.abs(n)
35  if (abs >= 1_000_000) return `${+(n / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}M`
36  if (abs >= 1_000) return `${+(n / 1_000).toFixed(abs >= 100_000 ? 0 : 1)}k`
37  return String(Math.round(n))
38}
39const span = (ms: number) => {
40  const m = Math.max(0, Math.round(ms / 60_000))
41  if (m < 60) return `${m}m`
42  const h = Math.floor(m / 60)
43  if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
44  return `${Math.floor(h / 24)}d${h % 24}h`
45}
46const money = (usd: number) => `$${usd.toFixed(2)}`
47const level = (percent: number) =>
48  percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success'
49const LIMIT_NAME: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
50
51const sparkline = (deltas: number[]) => {
52  const top = Math.max(1, ...deltas)
53  return deltas
54    .map(d => SPARKS[d <= 0 ? 0 : Math.min(7, Math.round((d / top) * 7))])
55    .join('')
56}
57
58// ---------- the mod ----------
59
60export const register: Register = (on, options) => {
61  const ttlMs = options.cacheTtl === '5m' ? 5 * 60_000 : 60 * 60_000
62  // Context and cost when the current main-loop turn began.
63  let turnStart: { tokens?: number; usd?: number } = {}
64
65  on('session.start', async ($, e, next) => {
66    const started = await next(e)
67    const usage = await $.session.usage()
68    await update($, meter, () => toMeter(usage))
69    // Cache and reset countdowns move by the minute.
70    $.clock.every(30_000, () => $.ui.invalidate('ui.render'))
71
72    return started
73  })
74
75  on('session.measure', async ($, e, next) => {
76    const measured = await next(e)
77    await update($, meter, () => toMeter(e))
78    if (e.changed.includes('context')) {
79      const now = await $.clock.now()
80      await update($, lastRequestAt, () => now)
81    }
82    return measured
83  }).catch(($, e, next) => next(e))
84
85  on('prompt.submit', async ($, e, next) => {
86    const m = await read($, meter)
87    turnStart = { tokens: m?.tokens, usd: m?.usd }
88    return next(e)
89  }).catch(($, e, next) => next(e))
90
91  on('turn.complete', async ($, e, next) => {
92    const done = await next(e)
93    if (e.agentId !== undefined) return done
94
95    const usage = await $.session.usage()
96    const after = toMeter(usage)
97    await update($, meter, () => after)
98    if (turnStart.tokens !== undefined && after.tokens !== undefined) {
99      const delta = after.tokens - turnStart.tokens
100      await update($, growth, list => [...list, delta].slice(-GROWTH_KEPT))
101    }
102    if (turnStart.usd !== undefined && after.usd !== undefined) {
103      const spent = after.usd - turnStart.usd
104      await update($, lastTurnUsd, () => spent)
105    }
106    turnStart = { tokens: after.tokens, usd: after.usd }
107    return done
108  }).catch(($, e, next) => next(e))
109
110  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111    const m = await read($, meter)
112    if (e.props.hasSurvey || m === null) return next(e)
113
114    const { Box, Text } = $.ui.resolve(e)
115    const now = await $.clock.now()
116    const deltas = await read($, growth)
117    const requestAt = await read($, lastRequestAt)
118    const turnUsd = await read($, lastTurnUsd)
119
120    // Each segment: its plain width (to fit the line), its drawing, and a
121    // priority; the lowest priorities drop first on a narrow terminal.
122    type Segment = { width: number; node: unknown; priority: number; isGlued?: boolean }
123    const segments: Segment[] = []
124
125    // ctx ■■■□□□□□□□ 27% 271k/1M
126    const percent = m.percent ?? (m.tokens !== undefined ? Math.round((m.tokens / m.window) * 100) : 0)
127    const filled = Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS))
128    const used = m.tokens !== undefined ? `${compact(m.tokens)}/` : ''
129    const ctxText = `ctx ${'■'.repeat(BAR_CELLS)} ${percent}% ${used}${compact(m.window)}`
130    segments.push({
131      width: ctxText.length,
132      priority: 100,
133      node: (
134        <Text>
135          <Text dimColor>ctx </Text>
136          <Text color={level(percent)}>{'■'.repeat(filled)}</Text>
137          <Text color="subtle">{'□'.repeat(BAR_CELLS - filled)}</Text>
138          <Text bold color={level(percent)}>
139            {' '}
140            {percent}%
141          </Text>
142          <Text dimColor>
143            {' '}
144            {used}
145            {compact(m.window)}
146          </Text>
147        </Text>
148      ),
149    })
150
151    // ▁▁▃█▁▇▁ ↑3.4k
152    if (deltas.length > 0) {
153      const last = deltas[deltas.length - 1]!
154      const arrow = last >= 0 ? `↑${compact(last)}` : `↓${compact(-last)}`
155      const spark = sparkline(deltas)
156      segments.push({
157        width: spark.length + 1 + arrow.length,
158        priority: 40,
159        isGlued: true,
160        node: (
161          <Text>
162            <Text color="claude">{spark}</Text>
163            <Text dimColor> {arrow}</Text>
164          </Text>
165        ),
166      })
167    }
168
169    // cache warm 38m
170    if (requestAt > 0) {
171      const left = requestAt + ttlMs - now
172      const isWarm = left > 0
173      const text = isWarm ? `cache warm ${span(left)}` : 'cache cold'
174      segments.push({
175        width: text.length,
176        priority: 60,
177        node: (
178          <Text>
179            <Text dimColor>cache </Text>
180            <Text color={isWarm ? (left < 5 * 60_000 ? 'warning' : 'suggestion') : 'inactive'}>
181              {isWarm ? `warm ${span(left)}` : 'cold'}
182            </Text>
183          </Text>
184        ),
185      })
186    }
187
188    // 5h 42% ↻2h34m · 7d 63% ↻2d7h
189    for (const limit of m.limits) {
190      const name = LIMIT_NAME[limit.kind] ?? limit.kind
191      const reset = limit.resetsAt !== undefined ? ` ↻${span(limit.resetsAt - now)}` : ''
192      const pct = `${Math.round(limit.percent)}%`
193      segments.push({
194        width: name.length + 1 + pct.length + reset.length,
195        priority: limit.kind === 'five_hour' ? 80 : 70,
196        node: (
197          <Text>
198            <Text dimColor>{name} </Text>
199            <Text bold color={level(limit.percent)}>
200              {pct}
201            </Text>
202            <Text dimColor>{reset}</Text>
203          </Text>
204        ),
205      })
206    }
207
208    // cost ≈$1.84 (+$0.12)
209    if (m.usd !== undefined) {
210      const turn = turnUsd !== null && turnUsd > 0 ? ` (+${money(turnUsd)})` : ''
211      segments.push({
212        width: 6 + money(m.usd).length + turn.length,
213        priority: 50,
214        node: (
215          <Text>
216            <Text dimColor>cost </Text>
217            <Text color="warning">≈{money(m.usd)}</Text>
218            <Text dimColor>{turn}</Text>
219          </Text>
220        ),
221      })
222    }
223
224    // Drop the lowest priorities until the line fits.
225    // Separators: ' │ ' between segments, two spaces before a glued one.
226    const room = e.props.bodyColumns
227    const kept = [...segments]
228    const widthOf = (list: Segment[]) =>
229      list.reduce((n, s, i) => n + s.width + (i === 0 ? 0 : s.isGlued ? 2 : 3), 0)
230    while (kept.length > 1 && widthOf(kept) > room) {
231      const lowest = kept.reduce((a, b) => (b.priority < a.priority ? b : a))
232      kept.splice(kept.indexOf(lowest), 1)
233    }
234
235    return (
236      <Box flexDirection="row">
237        <Text wrap="truncate-end">
238          {kept.map((s, i) => (
239            <Text>
240              {i > 0 && (s.isGlued ? <Text>  </Text> : <Text color="subtle"> │ </Text>)}
241              {s.node as never}
242            </Text>
243          ))}
244        </Text>
245      </Box>
246    )
247  })
248}
249
types/index.d.ts 21 lines
1export type Limit = { kind: string; percent: number; resetsAt?: number }
2export type Meter = {
3  tokens?: number
4  window: number
5  percent?: number
6  limits: Limit[]
7  usd?: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'ctx-band': {
13      meter: Meter | null
14      /** Context growth of the last main-loop turns, in tokens. */
15      growth: number[]
16      lastRequestAt: number
17      lastTurnUsd: number | null
18    }
19  }
20}
21