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

Function-hook mods for Claude Code. Each folder is one plugin.
| Mod | What it does | ||
|---|---|---|---|
context-watch | One-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-spinner | Spinner 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-watch | Tracks 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-style | No 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-safe | Quotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain | ||
pm-guard | Blocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile | ||
remote-guard | Asks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh | ||
xcode-status | Only 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-models | Pins 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-rules | Blocks 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 on | off | auto` overrides per repo (kept across sessions) |
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
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/.
hooks/register.tsx 417 lines1import { 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}
417types/index.d.ts 8 lines1export type EditedFiles = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'context-watch': { edited: EditedFiles; warned: number[]; tick: number }
6 }
7}
8