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

Two Claude Code mods that keep an eye on your session.
A side pane with two tabs (switch with 1 / 2, or open it with /session-pane):
+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./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.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.
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.
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.
MIT
hooks/register.tsx 249 lines1import { 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}
249types/index.d.ts 21 lines1export 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