SLOPSHOPPER

statusline

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

newspinnerguard
v0.5.1no licenseupdated 2026-10-12sidhantunnithan/jindan-cc-marketplace/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 ⟨Claude Code's own drawing⟩ ⟨Claude Code's own drawing⟩ · ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49%

Draws

Prompt hint
Opus 5.5 /work/app ⟨Claude Code's own drawing⟩
README

jindan-cc-marketplace

Personal Claude Code mods.

Install

One mod, in one step:

/plugin install statusline --marketplace sidhantunnithan/jindan-cc-marketplace

Or add the marketplace once, then install any mod from it:

/plugin marketplace add sidhantunnithan/jindan-cc-marketplace
/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). 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, and Show model / Show directory / Show context bar.
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 207 lines
1import type { ContextCategory, Register } from 'claude-code'
2
3const WIDTH = 20
4
5// Catppuccin Mocha as its nearest xterm-256 colours. Inside tmux Claude Code rounds each
6// channel to six steps (round(v / 51)), so each value sits in the step of the palette
7// entry it stands for and lands on exactly that entry; elsewhere it draws as given,
8// a few points off the entry at most.
9const MOCHA = {
10  teal: '#7fd7d7',
11  green: '#7fd77f',
12  yellow: '#ffd7af',
13  red: '#ff7faf',
14  blue: '#7fafff',
15  overlay1: '#7f7faf',
16  surface2: '#4c4c7f',
17} as const
18
19const colorFor = (percent: number) =>
20  percent >= 80 ? MOCHA.red : percent >= 60 ? MOCHA.yellow : MOCHA.green
21
22type Segment = { row: ContextCategory; cells: number }
23
24// Splits `width` cells across the rows by tokens (largest remainder), giving every
25// non-empty `used` row at least one cell so small categories stay visible.
26const allocate = (rows: ContextCategory[], width = WIDTH): Segment[] => {
27  const total = rows.reduce((sum, row) => sum + row.tokens, 0)
28  if (total <= 0) return []
29  const parts = rows.map(row => {
30    const exact = (row.tokens / total) * width
31    const min = row.kind === 'used' && row.tokens > 0 ? 1 : 0
32    return { row, exact, min, cells: Math.max(min, Math.floor(exact)) }
33  })
34  let left = width - parts.reduce((sum, p) => sum + p.cells, 0)
35
36  const byRemainder = [...parts].sort((a, b) => (b.exact - b.cells) - (a.exact - a.cells))
37  for (let k = 0; left > 0; k++, left--) byRemainder[k % byRemainder.length]!.cells++
38
39  // The minimum cells can overshoot: take them back from the widest segments.
40  while (left < 0) {
41    const widest = parts.filter(p => p.cells > p.min).sort((a, b) => b.cells - a.cells)[0]
42    if (!widest) break
43    widest.cells--
44    left++
45  }
46
47  return parts.filter(p => p.cells > 0).map(({ row, cells }) => ({ row, cells }))
48}
49
50const glyph = { used: '▰', free: '▱', buffer: '▒' } as const
51
52// Simple mode: grey for the system's own context, blue for what you add, yellow for the conversation.
53// The breakdown has no kind finer than `used`, so the group is read off the row's /context label.
54const SIMPLE = [
55  { name: 'System', color: MOCHA.overlay1 },
56  { name: 'Custom', color: MOCHA.blue },
57  { name: 'Messages', color: MOCHA.yellow },
58] as const
59
60const groupOf = (row: ContextCategory) =>
61  /^messages$/i.test(row.name) ? 2 : /agent|memory|skill/i.test(row.name) ? 1 : 0
62
63const simplify = (used: ContextCategory[]): ContextCategory[] =>
64  SIMPLE.map((group, i) => ({
65    name: group.name,
66    color: group.color,
67    kind: 'used' as const,
68    isDeferred: false,
69    tokens: used.filter(row => groupOf(row) === i).reduce((sum, row) => sum + row.tokens, 0),
70  }))
71
72// Shorthand labels for the line under the bar; an unlisted category falls back to its first word.
73const SHORT: [RegExp, string][] = [
74  [/^system prompt/i, 'sys'],
75  [/^system tools/i, 'tools'],
76  [/^mcp/i, 'mcp'],
77  [/agent/i, 'agents'],
78  [/memory/i, 'mem'],
79  [/skill/i, 'skills'],
80  [/command/i, 'cmds'],
81  [/^messages/i, 'msgs'],
82  [/^system$/i, 'sys'],
83  [/^custom$/i, 'custom'],
84]
85
86const shortName = (name: string) =>
87  SHORT.find(([pattern]) => pattern.test(name))?.[1] ?? (name.split(' ')[0] ?? name).toLowerCase()
88
89const shortTokens = (n: number) =>
90  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 1_000)}k` : `${n}`
91
92const MAX_CWD = 40
93
94// Home as ~, then cut from the left so the end of the path stays readable.
95const shortCwd = (cwd: string, home: string | undefined) => {
96  const path = home && (cwd === home || cwd.startsWith(`${home}/`)) ? `~${cwd.slice(home.length)}` : cwd
97  return path.length > MAX_CWD ? `…${path.slice(-(MAX_CWD - 1))}` : path
98}
99
100// $.session.model() gives the id (`claude-opus-5-5[1m]`); show it the way the welcome banner does.
101const modelName = (id: string) => {
102  const match = /^(?:claude-)?([a-z]+)((?:-\d{1,2})*)(?:-\d{8})?(\[1m\])?$/i.exec(id)
103  if (!match) return id
104  const [, family = '', version = '', isLong] = match
105  const name = family[0]!.toUpperCase() + family.slice(1) + (version ? ` ${version.slice(1).replace(/-/g, '.')}` : '')
106  return isLong ? `${name} (1M context)` : name
107}
108
109type Show = { model: boolean; cwd: boolean; bar: boolean }
110
111export const register: Register = (on, options) => {
112  const mode = options.mode === 'granular' ? 'granular' : 'simple'
113  const show: Show = {
114    model: options.showModel !== false,
115    cwd: options.showCwd !== false,
116    bar: options.showBar !== false,
117  }
118
119  // Model and directory at the left of the footer, drawn with the engine's hint line.
120  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
121    const engine = await next(e)
122    if (!show.model && !show.cwd) return engine
123    const { Box, Text } = $.ui.resolve(e)
124    return (
125      <Box flexDirection="column">
126        <Box>
127          {show.model ? <Text color={MOCHA.teal}>{modelName(await $.session.model())}</Text> : null}
128          {show.model && show.cwd ? <Text> </Text> : null}
129          {show.cwd ? <Text color={MOCHA.green}>{shortCwd(await $.session.cwd(), await $.env.get('HOME'))}</Text> : null}
130        </Box>
131        {engine}
132      </Box>
133    )
134  })
135
136  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
137    const { Box, Text } = $.ui.resolve(e)
138    const engine = await next(e)
139
140    if (!show.bar) return engine
141
142    const { context } = await $.session.usage({ breakdown: 'summary' })
143    const percent = context.percent
144
145    // Deferred tool schemas sit outside the window; /context leaves them off its grid too.
146    const rows = (context.breakdown?.categories ?? []).filter(row => row.kind !== 'deferred')
147    const categories = rows.filter(row => row.kind === 'used')
148    const used = mode === 'simple' ? simplify(categories) : categories
149    const segments = allocate([...used, ...rows.filter(row => row.kind === 'free'), ...rows.filter(row => row.kind === 'buffer')])
150
151    let bar
152    if (segments.length > 0) {
153      bar = segments.map(({ row, cells }) => (
154        <Text color={row.kind === 'used' ? row.color : MOCHA.surface2}>
155          {glyph[row.kind as keyof typeof glyph].repeat(cells)}
156        </Text>
157      ))
158    } else {
159      const filled = percent === undefined ? 0 : Math.round((percent / 100) * WIDTH)
160      bar = [
161        <Text color={percent === undefined ? MOCHA.surface2 : colorFor(percent)}>{'▰'.repeat(filled)}</Text>,
162        <Text color={MOCHA.surface2}>{'▱'.repeat(WIDTH - filled)}</Text>,
163      ]
164    }
165
166    const labelled = used.filter(row => row.tokens > 0)
167    const legend = labelled.map((row, i) => (
168      <Text color={row.color}>
169        {i > 0 ? ' · ' : ''}{shortName(row.name)} {shortTokens(row.tokens)}
170      </Text>
171    ))
172
173    return (
174      <Box flexDirection="column" alignItems="flex-end">
175        <Box>
176          {engine}
177          {e.props.modes.length > 0 ? <Text dimColor> · </Text> : null}
178          {bar}
179          <Text color={percent === undefined ? MOCHA.surface2 : colorFor(percent)}>
180            {' '}{percent === undefined ? '--' : `${percent}%`}
181          </Text>
182        </Box>
183        {legend.length > 0 ? <Box>{legend}</Box> : null}
184      </Box>
185    )
186  })
187
188  // Context grows with each API response: redraw after turns, tool calls and compactions.
189  on('turn.complete', async ($, e, next) => {
190    const result = await next(e)
191    $.ui.invalidate('ui.render')
192    return result
193  })
194
195  on('session.compact', async ($, e, next) => {
196    const result = await next(e)
197    $.ui.invalidate('ui.render')
198    return result
199  }).catch(($, e, next) => next(e))
200
201  on('tool.call', async ($, e, next) => {
202    const result = await next(e)
203    $.ui.invalidate('ui.render')
204    return result
205  }).catch(($, e, next) => next(e))
206}
207