SLOPSHOPPER

context-watch

Context fill, cost and rate limits in the status line; threshold toasts; compaction that keeps edited files; /ctx breakdown

newpanebandguardcommandtoast
v0.5.1no licenseupdated 2026-10-09narrowstacks/claude-code-mods/context-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-watch
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ ████████████████████░░░░░░░░░░░░░░░░░░░░ 49% │ ┃ 97k of 200k tokens · $0.42 · 5h 31% ⏺ Read(src/auth.ts) │ ┃ Edited this session (3) ⎿ Read 6 lines │ ┃ src/auth.ts src/audit.ts src/cache.ts ⏺ Update(src/auth.ts) │ ┃ [ Close ] ⎿ 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 │ ⎿ context-watch: Context pane opened. │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k · $0.42 · 5h 31% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k · $0.42 · 5h 31% ⟨Claude Code's own drawing⟩
Pane · Context
████████████████████░░░░░░░░░░░░░░░░░░░░ 49% 97k of 200k tokens · $0.42 · 5h 31% Edited this session (3) src/auth.ts src/audit.ts src/cache.ts [ Close ]
README

claude-code-mods

Function-hook mods for Claude Code. Each folder is one plugin.

ModWhat it does
context-watchOne-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane
live-spinnerSpinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet
pr-watchTracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs
house-styleNo em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one
handoff/carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop
agent-jobs/jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes
zsh-safeQuotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain
pm-guardBlocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile
remote-guardAsks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh
xcode-statusOnly in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result
agent-modelsPins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on
worktrees/worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once
pr-rulesBlocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules onoffauto` overrides per repo (kept across sessions)

Install

claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods

Or load from a checkout without installing:

claude --plugin-dir ./context-watch --plugin-dir ./live-spinner

Check

claude plugin validate ./context-watch
claude plugin test ./context-watch

tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.

Source 2 files
hooks/register.tsx 417 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4const edited = atom({ plugin: 'context-watch', key: 'edited' } as const, [])
5const warned = atom({ plugin: 'context-watch', key: 'warned' } as const, [])
6const tick = atom({ plugin: 'context-watch', key: 'tick' } as const, 0)
7
8const PANE = 'context-watch'
9const THRESHOLDS = [60, 80, 90]
10const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
11const PALETTE = ['cyan', 'magenta', 'blue', 'yellow', 'green', 'red', 'white']
12
13const shortPath = (path: string) => path.split('/').slice(-2).join('/')
14
15const formatK = (tokens: number) =>
16  tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
17
18// Hex twins of PALETTE for Svg, which draws as an isolated image.
19const PALETTE_HEX = ['#39c5cf', '#bc8cff', '#58a6ff', '#d29922', '#3fb950', '#ff7b72', '#f778ba']
20
21type Segment = { name: string; tokens: number; color: string; opacity?: number }
22
23// One stacked bar of the window; the legend beneath it names each segment.
24export const stackSvg = (segments: Segment[], total: number, width = 600, height = 14) => {
25  let x = 0
26  const rects = segments.map(seg => {
27    const w = total > 0 ? (seg.tokens / total) * width : 0
28    const rect = `<rect x="${x.toFixed(2)}" width="${w.toFixed(2)}" height="${height}" fill="${seg.color}" fill-opacity="${seg.opacity ?? 1}"/>`
29    x += w
30
31    return rect
32  })
33
34  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
35    + `<clipPath id="c"><rect width="${width}" height="${height}" rx="5"/></clipPath>`
36    + `<g clip-path="url(#c)"><rect width="${width}" height="${height}" fill="#8b949e" fill-opacity="0.15"/>${rects.join('')}</g></svg>`
37}
38
39// claude.ai connectors register under a bare UUID, so shorten those to something readable.
40const serverLabel = (name: string) =>
41  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name) ? `connector ${name.slice(0, 8)}` : name
42
43// Loaded MCP tools summed by server, heaviest first; unloaded ones take no context yet.
44export const mcpServers = (tools: readonly { serverName: string; tokens: number; isLoaded: boolean }[], limit = 6) => {
45  const byServer = new Map<string, { name: string; tokens: number; tools: number }>()
46  for (const tool of tools.filter(t => t.isLoaded)) {
47    const server = byServer.get(tool.serverName) ?? { name: serverLabel(tool.serverName), tokens: 0, tools: 0 }
48    server.tokens += tool.tokens
49    server.tools += 1
50    byServer.set(tool.serverName, server)
51  }
52
53  return [...byServer.values()].sort((a, b) => b.tokens - a.tokens).slice(0, limit)
54}
55
56const swatchSvg = (color: string, opacity = 1) =>
57  `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><rect width="10" height="10" rx="2.5" fill="${color}" fill-opacity="${opacity}"/></svg>`
58
59const formatTokens = (tokens: number) =>
60  tokens >= 1_000_000 ? `${+(tokens / 1_000_000).toFixed(1)}M` : formatK(tokens)
61
62export const bar = (fraction: number, width: number, full = '▰', empty = '▱') => {
63  const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
64
65  return full.repeat(filled) + empty.repeat(width - filled)
66}
67
68const heat = (percent: number) => (percent >= 80 ? 'red' : percent >= 60 ? 'yellow' : 'green')
69
70// Svg draws as an isolated image, so it takes fixed colors that read on light and dark alike.
71const HEAT_HEX = { green: '#3fb950', yellow: '#d29922', red: '#f85149' }
72
73// A rounded gauge for surfaces with Svg, ticked at the toast thresholds when `ticks` is set.
74export const gaugeSvg = (percent: number, width: number, height: number, ticks = false, color = HEAT_HEX[heat(percent)]) => {
75  const r = height / 2
76  const fill = Math.min(width, Math.max(0, (percent / 100) * width))
77  const marks = ticks
78    ? THRESHOLDS.slice(0, 2).map(t => `<rect x="${(t / 100) * width - 0.5}" y="0" width="1" height="${height}" fill="#8b949e" fill-opacity="0.6"/>`).join('')
79    : ''
80
81  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
82    + `<clipPath id="c"><rect width="${width}" height="${height}" rx="${r}"/></clipPath>`
83    + `<g clip-path="url(#c)"><rect width="${width}" height="${height}" fill="#8b949e" fill-opacity="0.22"/>`
84    + `<rect width="${fill}" height="${height}" fill="${color}"/>${marks}</g></svg>`
85}
86
87const limitName = (kind: string) =>
88  kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind
89
90// "resets in 2h 10m", from the window's ISO reset time.
91export const resetsIn = (resetsAt: string | undefined, now: number) => {
92  const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
93  if (Number.isNaN(at)) {
94    return undefined
95  }
96
97  const minutes = Math.max(0, Math.round((at - now) / 60_000))
98  const days = Math.floor(minutes / 1440)
99  const hours = Math.floor((minutes % 1440) / 60)
100  const left = days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
101
102  return `resets in ${left}`
103}
104
105export const statusText = (usage: SessionUsage) => {
106  const parts: string[] = []
107  const { percent, tokens, window } = usage.context
108
109  if (percent !== undefined) {
110    parts.push(`ctx ${bar(percent / 100, 10)} ${percent}% ${formatK(tokens ?? 0)}/${formatK(window)}`)
111  }
112  if (usage.cost !== undefined) {
113    parts.push(`$${usage.cost.usd.toFixed(2)}`)
114  }
115  for (const limit of usage.rateLimits) {
116    parts.push(`${limitName(limit.kind)} ${Math.round(limit.percentUsed)}%`)
117  }
118
119  return parts.join('  ·  ')
120}
121
122const refresh = async ($: EngineInterface) => {
123  const usage = await $.session.usage()
124  await update($, tick, n => n + 1)
125
126  const percent = usage.context.percent
127  if (percent === undefined) {
128    return
129  }
130
131  const already = await read($, warned)
132  const crossed = THRESHOLDS.filter(t => percent >= t && !already.includes(t))
133  if (crossed.length === 0) {
134    return
135  }
136
137  await update($, warned, list => [...list, ...crossed])
138  const top = Math.max(...crossed)
139  const hint = top >= 80 ? ' Consider /compact with a focus, or /clear for a new task.' : ''
140  $.ui.toast(`Context at ${percent}%.${hint}`, { timeoutMs: 8000 })
141}
142
143// Best effort from places that must not wait on it: a failed read only skips one refresh.
144const refreshLater = ($: EngineInterface) => {
145  refresh($).catch(() => undefined)
146}
147
148export const register: Register = on => {
149  on('session.start', async ($, e, next) => {
150    await $.command.register({
151      name: 'ctx',
152      description: 'Context window pane: what is using tokens',
153    })
154    // Clears the status line an earlier version of this mod pinned.
155    $.ui.status(undefined)
156    refreshLater($)
157
158    return next(e)
159  })
160
161  on('tool.call', async ($, e, next) => {
162    const ran = await next(e)
163    if (EDIT_TOOLS.has(e.tool) && 'file_path' in e && typeof e.file_path === 'string' && !ran.isError) {
164      const path = e.file_path
165      await update($, edited, list => [...list.filter(p => p !== path), path].slice(-50))
166    }
167
168    return ran
169  })
170
171  on('turn.complete', async ($, e, next) => {
172    const done = await next(e)
173    if (e.agentId === undefined) {
174      await refresh($)
175    }
176
177    return done
178  })
179
180  on('session.compact', async ($, e, next) => {
181    if (e.agentId !== undefined) {
182      return next(e)
183    }
184
185    const files = await read($, edited)
186    const keep = [
187      'Preserve verbatim: every explicit instruction or constraint the user gave, open TODOs, and the current next step.',
188      files.length > 0 ? `Files edited this session (keep this list): ${files.join(', ')}` : '',
189    ].filter(Boolean)
190    const instructions = [e.instructions, ...keep].filter(Boolean).join('\n')
191    const result = await next({ ...e, instructions })
192
193    await update($, warned, () => [])
194    refreshLater($)
195
196    return result
197  })
198
199  on('command.run', { command: 'ctx' }, async $ => {
200    await $.ui.open({ id: PANE, title: 'Context' })
201    await update($, tick, n => n + 1)
202
203    return { text: 'Context pane opened.' }
204  })
205
206  // One line above the prompt, stacked over whatever the plugins beneath draw there.
207  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
208    const below = await next(e)
209    if (e.props.hasSurvey) {
210      return below
211    }
212
213    await read($, tick)
214    const usage = await $.session.usage().catch(() => undefined)
215    const percent = usage?.context.percent
216    if (usage === undefined || percent === undefined) {
217      return below
218    }
219
220    if (e.surface === 'desktop') {
221      const { Box, Text, Svg } = $.ui.resolve(e)
222      const now = await $.clock.now()
223
224      return (
225        <Box flexDirection="column">
226          <Box alignItems="center" columnGap={1}>
227            <Text dimColor>Context</Text>
228            <Svg source={gaugeSvg(percent, 160, 8, true)} alt={`Context ${percent}% full`} width={160} height={8} />
229            <Text bold color={percent >= 60 ? heat(percent) : undefined}>{percent}%</Text>
230            <Text dimColor>
231              {formatTokens(usage.context.tokens ?? 0)} / {formatTokens(usage.context.window)}
232            </Text>
233            {usage.cost !== undefined ? <Text dimColor>·  ${usage.cost.usd.toFixed(2)}</Text> : null}
234            {usage.rateLimits.map(limit => (
235              <Box key={`limit-${limit.kind}`} alignItems="center" columnGap={1}>
236                <Text dimColor>·  {limitName(limit.kind)}</Text>
237                <Svg source={gaugeSvg(limit.percentUsed, 48, 6)} alt={`${limitName(limit.kind)} limit ${Math.round(limit.percentUsed)}% used`} width={48} height={6} />
238                <Text color={limit.percentUsed >= 60 ? heat(limit.percentUsed) : undefined}>{Math.round(limit.percentUsed)}%</Text>
239                {resetsIn(limit.resetsAt, now) !== undefined
240                  ? <Box display="none" hover={{ display: 'flex' }}><Text dimColor>{resetsIn(limit.resetsAt, now)}</Text></Box>
241                  : null}
242              </Box>
243            ))}
244          </Box>
245          {below}
246        </Box>
247      )
248    }
249
250    const { Box, Text } = $.ui.resolve(e)
251    const extras = statusText(usage).split('  ·  ').slice(1)
252
253    return (
254      <Box flexDirection="column">
255        <Box>
256          <Text dimColor>ctx </Text>
257          <Text color={heat(percent)}>{bar(percent / 100, 10)}</Text>
258          <Text color={percent >= 60 ? heat(percent) : undefined}> {percent}%</Text>
259          <Text dimColor>
260            {' '}{formatK(usage.context.tokens ?? 0)}/{formatK(usage.context.window)}
261            {extras.map(part => `  ·  ${part}`).join('')}
262          </Text>
263        </Box>
264        {below}
265      </Box>
266    )
267  })
268
269  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270    const { Box, Text, Button } = $.ui.resolve(e)
271    await read($, tick)
272
273    const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
274    if (usage === undefined) {
275      return (
276        <Box flexDirection="column">
277          <Text dimColor>Context figures are not available yet.</Text>
278          <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
279        </Box>
280      )
281    }
282
283    const files = await read($, edited)
284    const breakdown = usage.context.breakdown
285
286    if (e.surface === 'desktop') {
287      const { Svg } = $.ui.resolve(e)
288      const total = breakdown?.rawMaxTokens ?? usage.context.window
289      const percent = usage.context.percent ?? breakdown?.percentage ?? 0
290      const categories = breakdown?.categories ?? []
291      const used = categories.filter(c => c.kind === 'used' && c.tokens > 0).sort((a, b) => b.tokens - a.tokens)
292      const segments: Segment[] = [
293        ...used.map((c, i) => ({ name: c.name, tokens: c.tokens, color: PALETTE_HEX[i % PALETTE_HEX.length] ?? '#58a6ff' })),
294        ...categories.filter(c => c.kind === 'buffer' && c.tokens > 0).map(c => ({ name: c.name, tokens: c.tokens, color: '#8b949e', opacity: 0.45 })),
295      ]
296      const servers = mcpServers(breakdown?.mcpTools ?? [])
297      const deferred = (breakdown?.mcpTools ?? []).filter(t => !t.isLoaded).length
298      const memory = [...(breakdown?.memoryFiles ?? [])].sort((a, b) => b.tokens - a.tokens).slice(0, 4)
299      const heaviest = servers[0]?.tokens ?? 1
300
301      return (
302        <Box flexDirection="column" rowGap={1}>
303          <Box alignItems="center" columnGap={1}>
304            <Text bold color={percent >= 60 ? heat(percent) : undefined}>{percent}%</Text>
305            <Text dimColor>
306              {formatTokens(usage.context.tokens ?? breakdown?.totalTokens ?? 0)} of {formatTokens(usage.context.window)} tokens
307              {usage.cost !== undefined ? `  ·  $${usage.cost.usd.toFixed(2)}` : ''}
308              {usage.rateLimits.map(l => `  ·  ${limitName(l.kind)} ${Math.round(l.percentUsed)}%`).join('')}
309            </Text>
310          </Box>
311          <Svg source={stackSvg(segments, total)} alt={`Context ${percent}% full`} />
312
313          <Box flexDirection="column">
314            {segments.map(seg => (
315              <Box key={`cat-${seg.name}`} alignItems="center" columnGap={1}>
316                <Svg source={swatchSvg(seg.color, seg.opacity)} alt={seg.name} width={10} height={10} />
317                <Text>{seg.name}</Text>
318                <Text dimColor>{formatTokens(seg.tokens)} · {((seg.tokens / total) * 100).toFixed(1)}%</Text>
319              </Box>
320            ))}
321          </Box>
322
323          {(servers.length > 0 || deferred > 0) && (
324            <Box flexDirection="column">
325              <Text bold>MCP servers in context</Text>
326              {servers.map(server => (
327                <Box key={`mcp-${server.name}`} alignItems="center" columnGap={1}>
328                  <Svg source={gaugeSvg((server.tokens / heaviest) * 100, 80, 6, false, '#58a6ff')} alt={`${server.name} ${formatTokens(server.tokens)}`} width={80} height={6} />
329                  <Text>{server.name}</Text>
330                  <Text dimColor>{formatTokens(server.tokens)} · {server.tools} tool{server.tools === 1 ? '' : 's'}</Text>
331                </Box>
332              ))}
333              {deferred > 0 && <Text dimColor>{deferred} more tool{deferred === 1 ? '' : 's'} load on demand and use no context until searched for</Text>}
334            </Box>
335          )}
336
337          {memory.length > 0 && (
338            <Box flexDirection="column">
339              <Text bold>Memory files</Text>
340              {memory.map(f => (
341                <Text key={f.path} dimColor>{formatTokens(f.tokens)}  {shortPath(f.path)}</Text>
342              ))}
343            </Box>
344          )}
345
346          {files.length > 0 && (
347            <Box flexDirection="column">
348              <Text bold>Edited this session ({files.length})</Text>
349              <Text dimColor>{files.slice(-6).map(shortPath).join('  ')}</Text>
350            </Box>
351          )}
352
353          <Box>
354            <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
355          </Box>
356        </Box>
357      )
358    }
359
360    const percent = usage.context.percent ?? breakdown?.percentage ?? 0
361    const columns = e.viewport?.columns ?? 80
362    const gaugeWidth = Math.max(10, Math.min(40, columns - 24))
363    const rowWidth = Math.max(8, Math.min(30, columns - 40))
364    const total = breakdown?.rawMaxTokens ?? usage.context.window
365
366    const used = (breakdown?.categories ?? [])
367      .filter(c => c.kind === 'used' && c.tokens > 0)
368      .sort((a, b) => b.tokens - a.tokens)
369    const mcp = [...(breakdown?.mcpTools ?? [])].sort((a, b) => b.tokens - a.tokens).slice(0, 4)
370    const memory = [...(breakdown?.memoryFiles ?? [])].sort((a, b) => b.tokens - a.tokens).slice(0, 4)
371
372    return (
373      <Box flexDirection="column">
374        <Box>
375          <Text color={heat(percent)}>{bar(percent / 100, gaugeWidth, '█', '░')}</Text>
376          <Text bold> {percent}%</Text>
377        </Box>
378        <Text dimColor>
379          {formatK(usage.context.tokens ?? breakdown?.totalTokens ?? 0)} of {formatK(usage.context.window)} tokens
380          {usage.cost !== undefined ? `  ·  $${usage.cost.usd.toFixed(2)}` : ''}
381          {usage.rateLimits.map(l => `  ·  ${limitName(l.kind)} ${Math.round(l.percentUsed)}%`).join('')}
382        </Text>
383
384        {used.length > 0 && <Text bold>{'\n'}By category</Text>}
385        {used.map((c, i) => (
386          <Box key={c.name}>
387            <Text>{c.name.padEnd(20).slice(0, 20)} </Text>
388            <Text color={PALETTE[i % PALETTE.length]}>{bar(c.tokens / total, rowWidth, '■', ' ')}</Text>
389            <Text dimColor> {formatK(c.tokens).padStart(5)}</Text>
390          </Box>
391        ))}
392
393        {mcp.length > 0 && <Text bold>{'\n'}Heaviest MCP tools</Text>}
394        {mcp.map(t => (
395          <Text key={t.name} dimColor>
396            {formatK(t.tokens).padStart(5)}  {t.name}
397          </Text>
398        ))}
399
400        {memory.length > 0 && <Text bold>{'\n'}Memory files</Text>}
401        {memory.map(f => (
402          <Text key={f.path} dimColor>
403            {formatK(f.tokens).padStart(5)}  {shortPath(f.path)}
404          </Text>
405        ))}
406
407        {files.length > 0 && <Text bold>{'\n'}Edited this session ({files.length})</Text>}
408        {files.length > 0 && <Text dimColor>{files.slice(-6).map(shortPath).join('  ')}</Text>}
409
410        <Box>
411          <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
412        </Box>
413      </Box>
414    )
415  })
416}
417
types/index.d.ts 8 lines
1export type EditedFiles = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'context-watch': { edited: EditedFiles; warned: number[]; tick: number }
6  }
7}
8