SLOPSHOPPER

klod

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

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-04iSach/klod/klod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · klod
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ Measuring… │ ⏺ 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 │ │ › /context-view │ ⎿ klod: Context pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
Measuring…
README

klod

A personal Claude Code mod: a side pane showing the context window live.

The pane

Features

  • Context grid: what fills the window, by category, updated as Claude works.
  • Prompt cache timer: time left before the cache lapses. An idle session is refreshed once, then compacted (toggle in the pane).
  • New-topic warning: a prompt that starts an unrelated task in a large context offers to clear first (toggle in the pane).
  • Usage: tokens in and out, cost at API prices, plan limits.
  • Agents: running subagents and the tool each is using.

Opens on start, or with /context-view.

Install

git clone https://github.com/iSach/klod.git ~/.claude/mods

Then in ~/.claude/settings.json:

"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/klod" }
Source 2 files
hooks/register.tsx 410 lines
1import { 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}
410
types/index.d.ts 8 lines
1export 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