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

Personal Claude Code mods.
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
| 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). 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-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 207 lines1import 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