Sidebar showing the context window live, as the /context grid by category

A personal Claude Code mod: a side pane showing the context window live.
Opens on start, or with /context-view.
git clone https://github.com/iSach/klod.git ~/.claude/mods
Then in ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/klod" }hooks/register.tsx 410 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const PANE = 'context-view'
5const TITLE = 'Context'
6const breakdown = atom({ plugin: 'klod', key: 'breakdown' } as const, null)
7
8// 'summary' estimates locally (no API calls), so it is cheap enough to run after every tool call.
9const refresh = async ($: EngineInterface) => {
10 const { context, cost, rateLimits } = await $.session.usage({ breakdown: 'summary' })
11 const fresh = context.breakdown
12 if (fresh) await update($, breakdown, () => fresh)
13 if (cost) await update($, usd, () => cost.usd)
14 await update($, limits, () => rateLimits)
15}
16
17// The plan's usage windows as the last response reported them; empty off a subscription.
18const limits = atom({ plugin: 'klod', key: 'limits' } as const, [])
19const LIMIT_NAMES: Record<string, string> = { five_hour: '5h limit', seven_day: '7d limit', spend_limit: 'Spend limit' }
20
21// The session's subagents, and the tool each one is in the middle of.
22const agents = atom({ plugin: 'klod', key: 'agents' } as const, [])
23const agentTools = atom({ plugin: 'klod', key: 'agentTools' } as const, {})
24
25const syncAgents = async ($: EngineInterface) => {
26 const list = await $.agent.list()
27 // Written only on a change: the list is asked every second.
28 if (JSON.stringify(list) !== JSON.stringify(await read($, agents))) await update($, agents, () => list)
29}
30
31const until = (ms: number) => {
32 const minutes = Math.max(0, Math.round(ms / 60_000))
33 const [d, h, m] = [Math.floor(minutes / 1440), Math.floor(minutes / 60) % 24, minutes % 60]
34
35 return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
36}
37
38// What the model's requests added up to since the mod loaded, subagents included, in three plain kinds:
39// input read for the first time (uncached, or being written to the cache), input reused from the cache
40// at a reduced rate, and what the model wrote.
41const tokens = atom({ plugin: 'klod', key: 'tokens' } as const, { fresh: 0, reused: 0, written: 0 })
42// The engine's own total for the session at API prices: the one figure that weighs the three kinds.
43const usd = atom({ plugin: 'klod', key: 'usd' } as const, null)
44
45// Paul Tol's Bright set, colorblind-safe; the largest category takes the first color.
46const PALETTE = ['#4477AA', '#EE6677', '#228833', '#CCBB44', '#66CCEE', '#AA3377', '#BBBBBB']
47
48// When the last main-thread request was sent: each request reads the prompt cache, which restarts its lifetime.
49const cachedAt = atom({ plugin: 'klod', key: 'cachedAt' } as const, null)
50// The API does not tell a mod the cache's lifetime: 1h is this plan's, 5m applies on API keys and in usage overage.
51const CACHE_TTL_MS = 60 * 60_000
52
53// Keeping an idle session cheap, shortly before the cache lapses. The first near-miss after a real request
54// sends a tiny tool-less request over the same transcript (billed a cache read of the whole context); the
55// second compacts instead, so what is re-cached on return is small. After that the cache is left to lapse.
56const REFRESH_BEFORE_MS = 5 * 60_000
57const nearMisses = atom({ plugin: 'klod', key: 'refreshes' } as const, 0)
58const isKeepWarm = atom({ plugin: 'klod', key: 'isKeepWarm' } as const, true)
59let isRefreshing = false
60
61const keepWarm = async ($: EngineInterface) => {
62 if (isRefreshing || !(await read($, isKeepWarm))) return
63 const at = await read($, cachedAt)
64 const misses = await read($, nearMisses)
65 if (at === null || misses >= 2) return
66 const sentAt = await $.clock.now()
67 const left = CACHE_TTL_MS - (sentAt - at)
68 if (left > REFRESH_BEFORE_MS || left <= 0) return
69 isRefreshing = true
70 try {
71 // Counted before it is answered, so a failing attempt is not retried every second.
72 await update($, nearMisses, n => n + 1)
73 if (misses === 0) {
74 const reply = await $.model.fork({ prompt: 'Reply with the single word: ok' })
75 if ('usage' in reply && reply.usage.cache_read_input_tokens > 0) await update($, cachedAt, () => sentAt)
76 } else {
77 await $.session.compact()
78 await update($, cachedAt, () => null)
79 void refresh($)
80 }
81 } catch {
82 // A compaction is refused while a turn runs: that turn's own requests keep the cache warm.
83 } finally {
84 isRefreshing = false
85 }
86}
87
88const toggleKeepWarm = async ($: EngineInterface) => {
89 const isOn = !(await read($, isKeepWarm))
90 await update($, isKeepWarm, () => isOn)
91 await $.store.set('isKeepWarm', isOn)
92}
93
94// The new-topic warning: before a prompt enters a large context, a small model judges whether it carries
95// on from the person's last few prompts, and when it does not the person is asked whether to clear first.
96const WARN_FROM_TOKENS = 50_000
97const SEND = 'Send anyway'
98const CLEAR = 'Clear, then send'
99const CANCEL = 'Cancel'
100const recentPrompts = atom({ plugin: 'klod', key: 'recentPrompts' } as const, [])
101const isTopicCheck = atom({ plugin: 'klod', key: 'isTopicCheck' } as const, true)
102
103const isNewTopic = async ($: EngineInterface, recent: readonly string[], text: string) => {
104 const reply = await $.model.complete({
105 model: 'haiku',
106 maxTokens: 5,
107 timeoutMs: 6000,
108 system:
109 'You judge whether a new message continues the task a user has been working on with a coding assistant. ' +
110 'Answer with one word: SAME or NEW. Answer NEW only when the message clearly starts an unrelated task. ' +
111 'Short replies, follow-ups, corrections and questions about the work so far are SAME. When unsure, SAME.',
112 prompt: `Earlier messages, oldest first:\n${recent.map(p => `- ${p}`).join('\n')}\n\nNew message:\n${text.slice(0, 600)}`,
113 })
114
115 // No answer (an error, the timeout) never holds a prompt back.
116 return reply.isAnswered && reply.text.trim().toUpperCase().startsWith('NEW')
117}
118
119const toggleTopicCheck = async ($: EngineInterface) => {
120 const isOn = !(await read($, isTopicCheck))
121 await update($, isTopicCheck, () => isOn)
122 await $.store.set('isTopicCheck', isOn)
123}
124
125const fmt = (n: number) =>
126 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
127
128export const register: Register = on => {
129 on('session.start', async ($, e, next) => {
130 await $.command.register({
131 name: 'context-view',
132 description: 'Show the live context window in a pane',
133 })
134 if ((await $.store.get('isKeepWarm')) === false) await update($, isKeepWarm, () => false)
135 if ((await $.store.get('isTopicCheck')) === false) await update($, isTopicCheck, () => false)
136 $.clock.every(1000, () => {
137 $.ui.invalidate('ui.render')
138 void keepWarm($)
139 void syncAgents($)
140 })
141 void $.ui.open({ id: PANE, title: TITLE })
142 void refresh($)
143
144 return next(e)
145 })
146
147 on('command.run', { command: 'context-view' }, async $ => {
148 await $.ui.open({ id: PANE, title: TITLE })
149 await refresh($)
150
151 return { text: 'Context pane opened.' }
152 })
153
154 // Live while Claude works: every model request of the main thread, as it leaves and as it is answered.
155 on('turn.step', async function* ($, e, next) {
156 // The cache timer follows the main thread alone: not a subagent's request, and not the keep-warm
157 // requests themselves, which must not earn another round.
158 const isMain = e.agentId === undefined && !isRefreshing
159 if (isMain) {
160 const sentAt = await $.clock.now()
161 await update($, cachedAt, () => sentAt)
162 await update($, nearMisses, () => 0)
163 void refresh($)
164 }
165 const answered = yield* next(e)
166 const used = answered.usage
167 if (used) {
168 await update($, tokens, t => ({
169 fresh: t.fresh + used.input_tokens + used.cache_creation_input_tokens,
170 reused: t.reused + used.cache_read_input_tokens,
171 written: t.written + used.output_tokens,
172 }))
173 }
174 if (isMain) void refresh($)
175
176 return answered
177 })
178
179 on('prompt.submit', async ($, e, next) => {
180 const text = e.text.trim()
181 // The person's own typed prompts alone; one with attachments could not be sent again after a clear.
182 if (e.origin.kind !== 'composer' || text.startsWith('/') || e.attachments !== undefined) return next(e)
183 const recent = await read($, recentPrompts)
184 const remember = () => update($, recentPrompts, list => [...list, text.slice(0, 300)].slice(-6))
185 const size = (await read($, breakdown))?.totalTokens ?? 0
186 const isChecked = (await read($, isTopicCheck)) && e.turnId === undefined && recent.length > 0
187 if (!isChecked || size < WARN_FROM_TOKENS || !(await isNewTopic($, recent, text))) {
188 await remember()
189
190 return next(e)
191 }
192
193 // Dismissed (Esc) is Cancel.
194 const answer = await $.ui
195 .ask(`This looks like a new topic, and ${fmt(size)} tokens of context would be sent with it. Clear first?`, {
196 header: 'New topic',
197 options: [SEND, CLEAR, CANCEL],
198 })
199 .catch(() => CANCEL)
200 if (answer === SEND) {
201 await remember()
202
203 return next(e)
204 }
205 // Either way the prompt is offered back in the box (Tab takes it), so a failed clear loses nothing.
206 void $.prompt.suggest({ text: e.text })
207 if (answer !== CLEAR) return { drop: 'Not sent. Press Tab to get the prompt back.' }
208 await update($, recentPrompts, () => [text.slice(0, 300)])
209 // Queued behind this hook: the clear, then the prompt again as the person's own words.
210 void $.command
211 .run({ command: 'clear' })
212 .then(() => $.prompt.submit({ text: e.text, asUser: true }))
213 .catch(() => $.ui.toast('Could not clear. Press Tab to get the prompt back.'))
214
215 return { drop: 'Clearing the conversation, then sending.' }
216 })
217
218 // A cleared or ended session leaves no topic to compare with.
219 on('session.end', async ($, e, next) => {
220 await update($, recentPrompts, () => [])
221
222 return next(e)
223 })
224
225 // Which tool a subagent is in the middle of.
226 on('tool.call', async ($, e, next) => {
227 const id = e.agentId
228 if (id === undefined) return next(e)
229 await update($, agentTools, tools => ({ ...tools, [id]: e.tool }))
230 try {
231 return await next(e)
232 } finally {
233 await update($, agentTools, ({ [id]: _, ...tools }) => tools)
234 }
235 })
236
237 // End of turn, compaction, /clear.
238 on('session.measure', ($, e, next) => {
239 void refresh($)
240
241 return next(e)
242 })
243
244 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245 const { Box, Text, Button } = $.ui.resolve(e)
246 const b = await read($, breakdown)
247 if (!b) return <Text dimColor>Measuring…</Text>
248
249 // Solid cells, two wide so they read as squares, one wide when the pane is too narrow
250 // (4 = the grid's outline and the pane's padding).
251 const cell = (b.gridRows[0]?.length ?? 0) * 2 + 4 <= e.props.bodyColumns ? '██' : '█'
252
253 // Own palette for what occupies the window; the free space is left empty, the buffer recedes in grey.
254 const used = b.categories.filter(c => c.kind === 'used' && c.tokens > 0).sort((x, y) => y.tokens - x.tokens)
255 const rest = b.categories.filter(c => c.kind !== 'used' && c.tokens > 0)
256 const colors = new Map(used.map((c, i) => [c.name, PALETTE[i % PALETTE.length]]))
257 const kinds = new Map(b.categories.map(c => [c.name, c.kind]))
258 const paint = (name: string) =>
259 colors.get(name) !== undefined
260 ? { color: colors.get(name) }
261 : { color: 'inactive', dimColor: true }
262 const gridWidth = (b.gridRows[0]?.length ?? 0) * cell.length
263 const fill = (name: string) => (kinds.get(name) === 'free' ? ' '.repeat(cell.length) : cell)
264
265 const at = await read($, cachedAt)
266 const refreshed = await read($, nearMisses)
267 const isOn = await read($, isKeepWarm)
268 const spent = await read($, tokens)
269 const cost = await read($, usd)
270 const left = at === null ? null : CACHE_TTL_MS - ((await $.clock.now()) - at)
271 const clock = (ms: number) =>
272 `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`
273 const barWidth = gridWidth + 2
274 const warm = Math.ceil((Math.max(0, left ?? 0) / CACHE_TTL_MS) * barWidth)
275
276 const now = await $.clock.now()
277 const windows = await read($, limits)
278 const all = await read($, agents)
279 const tools = await read($, agentTools)
280 // Every running agent, and the last three that ended.
281 const shown = [...all.filter(a => a.status === 'running'), ...all.filter(a => a.status !== 'running').slice(-3)]
282 const lineWidth = e.props.bodyColumns - 2
283
284 const rows = [...used, ...rest]
285 const nameWidth = Math.max(...rows.map(c => c.name.length))
286 const pct = (n: number) => `${((n / b.rawMaxTokens) * 100).toFixed(1)}%`
287
288 return (
289 <Box
290 flexDirection="column"
291 backgroundColor="#000000"
292 paddingX={1}
293 width={e.props.bodyColumns}
294 minHeight={e.props.scroll.bodyRows}
295 >
296 <Text>
297 <Text bold>{b.percentage}%</Text>
298 <Text dimColor>
299 {' '}
300 {fmt(b.totalTokens)} of {fmt(b.rawMaxTokens)} · {b.model}
301 </Text>
302 </Text>
303 {/* Outline of eighth-blocks hugging the cells: a border's line runs mid-cell, which leaves
304 twice the gap above and below as left and right. */}
305 <Text dimColor> {'▁'.repeat(gridWidth)}</Text>
306 {b.gridRows.map(row => (
307 <Text>
308 <Text dimColor>▕</Text>
309 {row.map(sq => (
310 <Text {...paint(sq.categoryName)}>{fill(sq.categoryName)}</Text>
311 ))}
312 <Text dimColor>▏</Text>
313 </Text>
314 ))}
315 <Text dimColor> {'▔'.repeat(gridWidth)}</Text>
316 <Text> </Text>
317 {rows.map(c => (
318 <Text dimColor={c.kind !== 'used'}>
319 <Text {...paint(c.name)}>{c.kind === 'buffer' || c.kind === 'used' ? '██' : ' '}</Text> {c.name.padEnd(nameWidth)}{' '}
320 {fmt(c.tokens).padStart(7)} {(c.kind === 'deferred' ? '' : pct(c.tokens)).padStart(6)}
321 </Text>
322 ))}
323 <Text> </Text>
324 {left === null ? (
325 <Text dimColor>Prompt cache: {refreshed >= 2 ? 'compacted while idle' : 'no request yet'}</Text>
326 ) : left <= 0 ? (
327 <Text>
328 Prompt cache <Text bold>expired</Text>
329 </Text>
330 ) : (
331 <Text>
332 Prompt cache <Text bold>{clock(left)}</Text>
333 <Text dimColor>
334 {' '}
335 left of {CACHE_TTL_MS / 60_000}m{refreshed > 0 ? ' · refreshed' : ''}
336 </Text>
337 </Text>
338 )}
339 <Text>
340 <Text color={PALETTE[0]}>{'█'.repeat(warm)}</Text>
341 <Text color="inactive" dimColor>
342 {'█'.repeat(barWidth - warm)}
343 </Text>
344 </Text>
345 <Button
346 key="keep-warm"
347 plain
348 dimColor
349 label={`auto refresh, then compact: ${isOn ? 'on' : 'off'}`}
350 onPress={() => void toggleKeepWarm($)}
351 />
352 <Button
353 key="topic-check"
354 plain
355 dimColor
356 label={`new-topic warning: ${(await read($, isTopicCheck)) ? 'on' : 'off'}`}
357 onPress={() => void toggleTopicCheck($)}
358 />
359 <Text> </Text>
360 {/* in: input read for the first time (reused cache reads left out); api: the session at API prices. */}
361 <Text>
362 <Text color="#4477AA">in {fmt(spent.fresh)}</Text>
363 <Text dimColor> · </Text>
364 <Text color="#EE6677">out {fmt(spent.written)}</Text>
365 {cost !== null && <Text dimColor> · </Text>}
366 {cost !== null && <Text color="#777777">api {cost.toFixed(2)}$</Text>}
367 </Text>
368 {windows.length > 0 && <Text> </Text>}
369 {windows.map(w => {
370 const filled = Math.round((Math.min(100, w.percentUsed) / 100) * barWidth)
371 const color = w.percentUsed >= 95 ? '#EE6677' : w.percentUsed >= 80 ? '#CCBB44' : PALETTE[0]
372
373 return (
374 <Box flexDirection="column">
375 <Text>
376 {LIMIT_NAMES[w.kind] ?? w.kind} <Text bold>{w.percentUsed}%</Text>
377 {w.resetsAt !== undefined && <Text dimColor> · resets in {until(Date.parse(w.resetsAt) - now)}</Text>}
378 </Text>
379 <Text>
380 <Text color={color}>{'█'.repeat(filled)}</Text>
381 <Text color="inactive" dimColor>
382 {'█'.repeat(barWidth - filled)}
383 </Text>
384 </Text>
385 </Box>
386 )
387 })}
388 {shown.length > 0 && <Text> </Text>}
389 {shown.length > 0 && (
390 <Text>
391 Agents <Text dimColor>· {all.filter(a => a.status === 'running').length} running</Text>
392 </Text>
393 )}
394 {shown.map(a => {
395 const isRunning = a.status === 'running'
396 const mark = isRunning ? '●' : a.status === 'completed' ? '✓' : '✗'
397 const color = isRunning ? '#228833' : a.status === 'completed' ? 'inactive' : '#EE6677'
398 const doing = isRunning && tools[a.id] !== undefined ? ` · ${tools[a.id]}` : ''
399
400 return (
401 <Text dimColor={!isRunning}>
402 <Text color={color}>{mark}</Text> {`${a.type}: ${a.description}${doing}`.slice(0, lineWidth - 2)}
403 </Text>
404 )
405 })}
406 </Box>
407 )
408 })
409}
410types/index.d.ts 8 lines1export type ContextViewTokens = { fresh: number; reused: number; written: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'klod': { breakdown: SessionContextBreakdown | null; cachedAt: number | null; refreshes: number; isKeepWarm: boolean; tokens: ContextViewTokens; usd: number | null; limits: SessionRateLimit[]; agents: AgentInfo[]; agentTools: Record<string, string>; recentPrompts: string[]; isTopicCheck: boolean }
6 }
7}
8