Prompt footer status line: model, directory and 5h limit bar at the left, a context window bar at the right

Personal Claude Code mods.
One mod, in one step:
/plugin install statusline --marketplace sidhantunnithan/claude-plugins
Or add the marketplace once, then install any mod from it:
/plugin marketplace add sidhantunnithan/claude-plugins
/plugin install <mod>@jindan
| Mod | What it does |
|---|---|
statusline | Status line in the prompt footer. At the left, the model (Opus 5.5 (1M context)) and working directory (~/Projects/foo, cut from the left past 40 characters), then the 5h rate-limit window as a thin blue bar with its percentage and reset time (5h ━━━━━━━━━━ 47% » 17:05, or 17:05 (EDT) in a chosen zone; left out off a subscription). At the right, a context window bar (▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▒▒▒ 32%) coloured by what fills the context. simple (default): grey for system (prompt, tools, MCP, commands), blue for custom (agents, memory files, skills), yellow for messages. granular: each category in its /context colour. Free space is ▱, the autocompact buffer ▒; the percentage is green below 60%, yellow below 80%, red above. A shorthand line under the bar names each segment (sys 16k · custom 3k · msgs 92k). In /config: Colour mode; Show model / Show directory / Show context bar / Show 5h limit bar; and 5h reset time zone (picked from a list of every tz-database zone, such as America/New_York; Host, the default, means the machine's own zone; any other zone is named after the time, or shown as its offset such as GMT+9 when it has no English abbreviation). |
tmux-session-rename | Names the tmux window after the conversation. /rename <name> renames it at once; a bare /rename takes the name Claude generates; resuming a named session (--resume, --continue) names it as the session starts. When the session ends (exit or /clear) the window goes back to tmux's automatic name. Does nothing outside tmux; needs no tmux.conf changes. |
.claude-plugin/plugin.json and hooks/.plugins in .claude-plugin/marketplace.json.claude plugin validate . and claude plugin test <mod>.hooks/register.tsx 274 lines1import type { ContextCategory, Register } from 'claude-code'
2
3const WIDTH = 20
4const FIVE_HOUR_WIDTH = 10
5
6// Catppuccin Mocha as its nearest xterm-256 colours. Inside tmux Claude Code rounds each
7// channel to six steps (round(v / 51)), so each value sits in the step of the palette
8// entry it stands for and lands on exactly that entry; elsewhere it draws as given,
9// a few points off the entry at most.
10const MOCHA = {
11 teal: '#7fd7d7',
12 green: '#7fd77f',
13 yellow: '#ffd7af',
14 red: '#ff7faf',
15 blue: '#7fafff',
16 overlay1: '#7f7faf',
17 surface2: '#4c4c7f',
18} as const
19
20const colorFor = (percent: number) =>
21 percent >= 80 ? MOCHA.red : percent >= 60 ? MOCHA.yellow : MOCHA.green
22
23type Segment = { row: ContextCategory; cells: number }
24
25// Splits `width` cells across the rows by tokens (largest remainder), giving every
26// non-empty `used` row at least one cell so small categories stay visible.
27const allocate = (rows: ContextCategory[], width = WIDTH): Segment[] => {
28 const total = rows.reduce((sum, row) => sum + row.tokens, 0)
29 if (total <= 0) return []
30 const parts = rows.map(row => {
31 const exact = (row.tokens / total) * width
32 const min = row.kind === 'used' && row.tokens > 0 ? 1 : 0
33 return { row, exact, min, cells: Math.max(min, Math.floor(exact)) }
34 })
35 let left = width - parts.reduce((sum, p) => sum + p.cells, 0)
36
37 const byRemainder = [...parts].sort((a, b) => (b.exact - b.cells) - (a.exact - a.cells))
38 for (let k = 0; left > 0; k++, left--) byRemainder[k % byRemainder.length]!.cells++
39
40 // The minimum cells can overshoot: take them back from the widest segments.
41 while (left < 0) {
42 const widest = parts.filter(p => p.cells > p.min).sort((a, b) => b.cells - a.cells)[0]
43 if (!widest) break
44 widest.cells--
45 left++
46 }
47
48 return parts.filter(p => p.cells > 0).map(({ row, cells }) => ({ row, cells }))
49}
50
51const glyph = { used: '▰', free: '▱', buffer: '▒' } as const
52
53// The 5h window as one thin continuous line, filled from the left.
54const slab = (width = FIVE_HOUR_WIDTH) => Array.from({ length: width }, () => '━')
55
56// Intl names a zone only in the locales that use it; these are the English ones that name any zone
57// (IST only in en-IN, SGT only in en-SG). A zone none of them names reads as its offset (GMT+9).
58const NAMING_LOCALES = ['en-CA', 'en-GB', 'en-BW', 'en-NZ', 'en-ID', 'en-SG', 'en-GU', 'en-IN', 'en-GY', 'en-HK', 'en-IE']
59
60// Reset time on a 24-hour clock in `timeZone` (an IANA name), then the zone's abbreviation;
61// host time, with no abbreviation, for Host, empty or unknown.
62const clockFormat = (timeZone: string | undefined) => {
63 const time = (zone?: string) =>
64 new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone })
65 const zone = timeZone && timeZone !== 'Host' ? timeZone : undefined
66 try {
67 const format = time(zone)
68 if (!zone) return (at: number) => format.format(at)
69 const names = NAMING_LOCALES.map(locale => new Intl.DateTimeFormat(locale, { timeZone: zone, timeZoneName: 'short' }))
70 const nameAt = (at: number) => {
71 const found = names.map(f => f.formatToParts(at).find(part => part.type === 'timeZoneName')?.value ?? '')
72 return found.find(name => name && !/^GMT[+-]|Time$/.test(name)) ?? found[0]
73 }
74 return (at: number) => `${format.format(at)} (${nameAt(at)})`
75 } catch {
76 const format = time()
77 return (at: number) => format.format(at)
78 }
79}
80
81const clockTime = (iso: string | undefined, clock: (at: number) => string) => {
82 const at = iso === undefined ? NaN : Date.parse(iso)
83 return Number.isNaN(at) ? '--:--' : clock(at)
84}
85
86// Simple mode: grey for the system's own context, blue for what you add, yellow for the conversation.
87// The breakdown has no kind finer than `used`, so the group is read off the row's /context label.
88const SIMPLE = [
89 { name: 'System', color: MOCHA.overlay1 },
90 { name: 'Custom', color: MOCHA.blue },
91 { name: 'Messages', color: MOCHA.yellow },
92] as const
93
94const groupOf = (row: ContextCategory) =>
95 /^messages$/i.test(row.name) ? 2 : /agent|memory|skill/i.test(row.name) ? 1 : 0
96
97const simplify = (used: ContextCategory[]): ContextCategory[] =>
98 SIMPLE.map((group, i) => ({
99 name: group.name,
100 color: group.color,
101 kind: 'used' as const,
102 isDeferred: false,
103 tokens: used.filter(row => groupOf(row) === i).reduce((sum, row) => sum + row.tokens, 0),
104 }))
105
106// Shorthand labels for the line under the bar; an unlisted category falls back to its first word.
107const SHORT: [RegExp, string][] = [
108 [/^system prompt/i, 'sys'],
109 [/^system tools/i, 'tools'],
110 [/^mcp/i, 'mcp'],
111 [/agent/i, 'agents'],
112 [/memory/i, 'mem'],
113 [/skill/i, 'skills'],
114 [/command/i, 'cmds'],
115 [/^messages/i, 'msgs'],
116 [/^system$/i, 'sys'],
117 [/^custom$/i, 'custom'],
118]
119
120const shortName = (name: string) =>
121 SHORT.find(([pattern]) => pattern.test(name))?.[1] ?? (name.split(' ')[0] ?? name).toLowerCase()
122
123const shortTokens = (n: number) =>
124 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 1_000)}k` : `${n}`
125
126const MAX_CWD = 40
127
128// Home as ~, then cut from the left so the end of the path stays readable.
129const shortCwd = (cwd: string, home: string | undefined) => {
130 const path = home && (cwd === home || cwd.startsWith(`${home}/`)) ? `~${cwd.slice(home.length)}` : cwd
131 return path.length > MAX_CWD ? `…${path.slice(-(MAX_CWD - 1))}` : path
132}
133
134// $.session.model() gives the id (`claude-opus-5-5[1m]`); show it the way the welcome banner does.
135const modelName = (id: string) => {
136 const match = /^(?:claude-)?([a-z]+)((?:-\d{1,2})*)(?:-\d{8})?(\[1m\])?$/i.exec(id)
137 if (!match) return id
138 const [, family = '', version = '', isLong] = match
139 const name = family[0]!.toUpperCase() + family.slice(1) + (version ? ` ${version.slice(1).replace(/-/g, '.')}` : '')
140 return isLong ? `${name} (1M context)` : name
141}
142
143type Show = { model: boolean; cwd: boolean; bar: boolean; fiveHour: boolean }
144
145export const register: Register = (on, options) => {
146 const mode = options.mode === 'granular' ? 'granular' : 'simple'
147 const show: Show = {
148 model: options.showModel !== false,
149 cwd: options.showCwd !== false,
150 bar: options.showBar !== false,
151 fiveHour: options.showFiveHour !== false,
152 }
153 const clock = clockFormat(typeof options.timeZone === 'string' ? options.timeZone.trim() : undefined)
154
155 // Model, directory and the 5h window at the left of the footer, drawn with the engine's hint line.
156 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
157 const engine = await next(e)
158 const { Box, Text } = $.ui.resolve(e)
159
160 // Off a subscription there is no 5h window, so no bar.
161 const { rateLimits } = show.fiveHour ? await $.session.usage() : { rateLimits: [] }
162 const fiveHour = rateLimits.find(limit => limit.kind === 'five_hour')
163 let fiveHourBar = null
164 if (fiveHour) {
165 const cells = slab()
166 const filled = Math.min(FIVE_HOUR_WIDTH, Math.round((fiveHour.percentUsed / 100) * FIVE_HOUR_WIDTH))
167 fiveHourBar = [
168 <Text color={MOCHA.blue}>5h </Text>,
169 <Text color={MOCHA.blue}>{cells.slice(0, filled).join('')}</Text>,
170 <Text color={MOCHA.surface2}>{cells.slice(filled).join('')}</Text>,
171 <Text color={MOCHA.blue}> {Math.round(fiveHour.percentUsed)}% » {clockTime(fiveHour.resetsAt, clock)}</Text>,
172 ]
173 }
174
175 if (!show.model && !show.cwd && !fiveHourBar) return engine
176 return (
177 <Box flexDirection="column">
178 <Box>
179 {show.model ? <Text color={MOCHA.teal}>{modelName(await $.session.model())}</Text> : null}
180 {show.model && show.cwd ? <Text> </Text> : null}
181 {show.cwd ? <Text color={MOCHA.green}>{shortCwd(await $.session.cwd(), await $.env.get('HOME'))}</Text> : null}
182 {(show.model || show.cwd) && fiveHourBar ? <Text> </Text> : null}
183 {fiveHourBar}
184 </Box>
185 {engine}
186 </Box>
187 )
188 })
189
190 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
191 const { Box, Text } = $.ui.resolve(e)
192 const engine = await next(e)
193
194 if (!show.bar) return engine
195
196 const { context } = await $.session.usage({ breakdown: 'summary' })
197
198 // Drawn against the model's whole window, not the compaction window the breakdown's free and
199 // buffer rows fill; deferred tool schemas sit outside it, as /context leaves them off its grid.
200 const categories = (context.breakdown?.categories ?? []).filter(row => row.kind === 'used')
201 const used = mode === 'simple' ? simplify(categories) : categories
202 const usedTokens = categories.reduce((sum, row) => sum + row.tokens, 0)
203 const free: ContextCategory = { name: 'Free space', color: MOCHA.surface2, kind: 'free', isDeferred: false, tokens: Math.max(0, context.window - usedTokens) }
204 const segments = categories.length > 0 ? allocate([...used, free]) : []
205
206 // The percent is read off the rows the bar draws, so the two never disagree; the API's
207 // figure is absent from a compaction to the next response.
208 const percent = segments.length > 0 ? Math.round((usedTokens / context.window) * 100) : context.percent
209
210 let bar
211 if (segments.length > 0) {
212 bar = segments.map(({ row, cells }) => (
213 <Text color={row.kind === 'used' ? row.color : MOCHA.surface2}>
214 {glyph[row.kind as keyof typeof glyph].repeat(cells)}
215 </Text>
216 ))
217 } else {
218 const filled = percent === undefined ? 0 : Math.round((percent / 100) * WIDTH)
219 bar = [
220 <Text color={percent === undefined ? MOCHA.surface2 : colorFor(percent)}>{'▰'.repeat(filled)}</Text>,
221 <Text color={MOCHA.surface2}>{'▱'.repeat(WIDTH - filled)}</Text>,
222 ]
223 }
224
225 const labelled = used.filter(row => row.tokens > 0)
226 const legend = labelled.map((row, i) => (
227 <Text color={row.color}>
228 {i > 0 ? ' · ' : ''}{shortName(row.name)} {shortTokens(row.tokens)}
229 </Text>
230 ))
231
232 return (
233 <Box flexDirection="column" alignItems="flex-end">
234 <Box>
235 {engine}
236 {e.props.modes.length > 0 ? <Text dimColor> · </Text> : null}
237 {bar}
238 <Text color={percent === undefined ? MOCHA.surface2 : colorFor(percent)}>
239 {' '}{percent === undefined ? '--' : `${percent}%`}
240 </Text>
241 </Box>
242 {legend.length > 0 ? <Box>{legend}</Box> : null}
243 </Box>
244 )
245 })
246
247 // Context grows with each API response: redraw after turns, tool calls and compactions.
248 on('turn.complete', async ($, e, next) => {
249 const result = await next(e)
250 $.ui.invalidate('ui.render')
251 return result
252 })
253
254 // A compaction reaches no hook of an installed plugin on Team and Enterprise organizations (the
255 // built-in security plugin passes it on past them), but it clears the API's figure: watch that.
256 on('session.start', async ($, e, next) => {
257 const result = await next(e)
258 if (!show.bar) return result
259 let last: number | undefined
260 $.clock.every(2000, async () => {
261 const { context } = await $.session.usage()
262 if (context.tokens !== last) $.ui.invalidate('ui.render')
263 last = context.tokens
264 })
265 return result
266 })
267
268 on('tool.call', async ($, e, next) => {
269 const result = await next(e)
270 $.ui.invalidate('ui.render')
271 return result
272 }).catch(($, e, next) => next(e))
273}
274