SLOPSHOPPER

statusline

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

newspinnerguardtimer
v0.8.1no licenseupdated 2026-10-26sidhantunnithan/claude-plugins/statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 /work/app 5h ━━━━━━━━━━ 31% » --:-- ⟨Claude Code's own drawing⟩ ⟨Claude Code's own drawing⟩ · ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49%

Draws

Prompt hint
Opus 5.5 /work/app 5h ━━━━━━━━━━ 31% » --:-- ⟨Claude Code's own drawing⟩
README

claude-plugins

Personal Claude Code mods.

Install

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

Mods

ModWhat it does
statuslineStatus 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-renameNames 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.

Adding a mod

  1. Put it in its own folder with .claude-plugin/plugin.json and hooks/.
  2. List it under plugins in .claude-plugin/marketplace.json.
  3. claude plugin validate . and claude plugin test <mod>.
Source 1 files
hooks/register.tsx 274 lines
1import 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