Colored dots for plan usage and context window fill

A Claude Code mod that shows your plan usage and context-window fill as small pie circles (○ ◔ ◑ ◕ ●) at the right, above the prompt. Green below 65%, yellow from 65%, red from 85%. Hover a gauge for details: reset time and 7-day usage, or tokens used and the biggest context category. While you type, the left of the band shows an estimate of the prompt's token cost (about 4 characters per token).


In a terminal Claude Code session:
/plugin install usage-dots --marketplace DiegoHermosilloP/usage-dots
From a shell:
claude plugin install usage-dots --marketplace DiegoHermosilloP/usage-dots
reply-quote: select text in the terminal and reply to it, like the desktop app's "Reply".
MIT
hooks/register.tsx 151 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Gauge } from '../types'
5
6const gauges = atom({ plugin: 'usage-dots', key: 'gauges' } as const, [] as Gauge[])
7
8const draftTokens = atom({ plugin: 'usage-dots', key: 'draftTokens' } as const, 0)
9
10// No tokenizer is exposed to mods, so this is the usual ~4 characters per token.
11const estimate = (text: string) =>
12 text.trim() === '' ? 0 : Math.ceil(Array.from(text).length / 4)
13
14const LABELS: Record<string, string> = {
15 five_hour: 'Usage',
16 spend_limit: 'Spend'
17}
18
19const colorOf = (percent: number) =>
20 percent >= 85 ? 'red' : percent >= 65 ? 'yellow' : 'green'
21
22const PIES = ['○', '◔', '◑', '◕', '●']
23const pieOf = (percent: number) =>
24 PIES[Math.min(4, Math.max(0, Math.round(percent / 25)))]
25
26const resetText = (iso: string | undefined) => {
27 if (!iso) return ''
28 const at = new Date(iso)
29 const mins = Math.max(0, Math.round((at.getTime() - Date.now()) / 60000))
30 const clock = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
31
32 return ` · resets in ${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m (${clock})`
33}
34
35const k = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1e3)}k`)
36
37const detailOf = (g: Gauge) => {
38 const parts = [`${Math.round(g.percent)}% used`]
39
40 if (g.tokens !== undefined && g.window !== undefined) {
41 parts.push(`${k(g.tokens)} of ${k(g.window)} tokens`)
42 }
43
44 if (g.top) parts.push(`biggest: ${g.top.name} (${k(g.top.tokens)})`)
45
46 if (g.resetsAt) parts.push(`resets in ${resetText(g.resetsAt).slice(11)}`)
47 if (g.weekly !== undefined) parts.push(`7-day: ${Math.round(g.weekly)}%`)
48
49 return `${g.label}: ${parts.join(' · ')}`
50}
51
52export const register: Register = on => {
53 on('prompt.edit', async ($, e, next) => {
54 const box = await next(e)
55
56 await update($, draftTokens, () => estimate(box.text))
57
58 return box
59 })
60
61 on('prompt.change', async ($, e, next) => {
62 await update($, draftTokens, () => estimate(e.text))
63
64 return next(e)
65 })
66
67 on('prompt.submit', async ($, e, next) => {
68 await update($, draftTokens, () => 0)
69
70 return next(e)
71 })
72
73 on('session.measure', async ($, e, next) => {
74 const next_: Gauge[] = e.rateLimits
75 .filter(r => r.kind in LABELS)
76 .map(r => ({
77 label: LABELS[r.kind],
78 percent: r.percentUsed,
79 resetsAt: r.resetsAt,
80 weekly:
81 r.kind === 'five_hour'
82 ? e.rateLimits.find(w => w.kind === 'seven_day')?.percentUsed
83 : undefined
84 }))
85
86 if (e.context.percent !== undefined) {
87 let top: Gauge['top']
88
89 try {
90 const { context } = await $.session.usage({ breakdown: 'summary' })
91 const used = (context.breakdown?.categories ?? []).filter(c => c.kind === 'used')
92 const biggest = used.sort((x, y) => y.tokens - x.tokens)[0]
93
94 if (biggest) top = { name: biggest.name, tokens: biggest.tokens }
95 } catch {}
96
97 next_.push({
98 top,
99 label: 'Context',
100 percent: e.context.percent,
101 tokens: e.context.tokens,
102 window: e.context.window
103 })
104 }
105
106 await update($, gauges, () => next_)
107
108 return next(e)
109 })
110
111 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
112 const list = await read($, gauges)
113 const draft = await read($, draftTokens)
114
115 if (e.props.hasSurvey || (list.length === 0 && draft === 0)) {
116 return next(e)
117 }
118
119 const { Box, Text } = $.ui.resolve(e)
120
121 return (
122 <Box flexDirection="column">
123 <Box>
124 <Box flexGrow={1}>
125 {draft > 0 ? <Text dimColor>Prompt ≈ {draft.toLocaleString()} tokens</Text> : null}
126 </Box>
127 {list.map(g => (
128 <Box key={g.label} hover={{ scope: `usage-dots:${g.label}` }}>
129 <Text color={colorOf(g.percent)}>{pieOf(g.percent)} {Math.round(g.percent)}%</Text>
130 <Text dimColor> {g.label}{resetText(g.resetsAt)} </Text>
131 </Box>
132 ))}
133 </Box>
134 <Box height={1}>
135 {list.map(g => (
136 <Box
137 key={`card-${g.label}`}
138 position="absolute"
139 right={0}
140 display="none"
141 hover={{ scope: `usage-dots:${g.label}`, display: 'flex' }}
142 >
143 <Text color={colorOf(g.percent)}>{detailOf(g)}</Text>
144 </Box>
145 ))}
146 </Box>
147 </Box>
148 )
149 })
150}
151types/index.d.ts 19 lines1export type Gauge = {
2 label: string
3 percent: number
4 resetsAt?: string
5 /** Context only: tokens in the window and the window's size. */
6 tokens?: number
7 window?: number
8 /** Context only: the largest category of what fills the window, and its tokens. */
9 top?: { name: string; tokens: number }
10 /** Usage only: the 7-day window's percent, shown in the hover card. */
11 weekly?: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'usage-dots': { gauges: Gauge[]; draftTokens: number }
17 }
18}
19