A band above the Claude Code prompt: your starship prompt (or a built-in fallback), plus model, context and rate-limit usage.

A Claude Code mod that draws a band above the prompt:
~/…/acme/payments-service on main * via 🐍 v3.11.15 (payments-env) on ☁️ (us-east-1)
claude-opus-5-5 · ctx 142k/1M 14% · 5h 23% ↻14:30 · 7d 41% ↻09:00
At the prompt of a Claude Code terminal session:
/plugin install session-bar --marketplace sourabp/claude-session-bar
From a shell:
claude plugin install session-bar --marketplace sourabp/claude-session-bar
Answer y to add the marketplace, then pick a scope.
Run /config and find the Prompt source row for this plugin (or set pluginConfigs["session-bar"].options.prompt in settings.json).
| Value | Behavior |
|---|---|
auto (default) | Use starship if it is installed and works, otherwise the built-in line. |
starship | Starship only. The top row is empty if starship is missing. |
builtin | Never run starship. |
Starship reads your usual ~/.config/starship.toml.
cd made inside Claude's shell shows up after the turn.limits n/a means the API reported no rate-limit windows. That is expected off a Claude subscription (API key or gateway auth) and before the first response.starship, git and python3 --version, reads a few environment variables (HOME, VIRTUAL_ENV, AWS_PROFILE, AWS_REGION, AWS_DEFAULT_REGION), and reads session state. It makes no network calls.claude --plugin-dir ./claude-session-bar
claude plugin validate ./claude-session-bar
claude plugin test ./claude-session-bar
Tests live in tests/ and run on every push and pull request through GitHub Actions (.github/workflows/ci.yml), plus weekly, to catch Claude Code releases that change the early-access mod API. They need no login or secrets.
MIT
hooks/register.jsx 225 lines1import { atom, read, update } from 'claude-code'
2
3const promptRuns = atom({ plugin: 'session-bar', key: 'prompt' }, [])
4const usageSegs = atom({ plugin: 'session-bar', key: 'usage' }, [])
5
6const BASIC = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
7
8const hex = n => n.toString(16).padStart(2, '0')
9
10const color256 = n => {
11 if (n < 16) return BASIC[n % 8]
12 if (n >= 232) {
13 const g = 8 + (n - 232) * 10
14 return `#${hex(g)}${hex(g)}${hex(g)}`
15 }
16 const i = n - 16
17 const level = v => (v === 0 ? 0 : 55 + v * 40)
18 return `#${hex(level(Math.floor(i / 36)))}${hex(level(Math.floor(i / 6) % 6))}${hex(level(i % 6))}`
19}
20
21// Parses starship's SGR output (colors, bold) into runs of styled text.
22const parseAnsi = text => {
23 const runs = []
24 let style = {}
25 let last = 0
26 const push = s => s && runs.push({ text: s, ...style })
27
28 for (const m of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
29 push(text.slice(last, m.index))
30 last = m.index + m[0].length
31
32 const codes = m[1] === '' ? [0] : m[1].split(';').map(Number)
33 for (let i = 0; i < codes.length; i++) {
34 const c = codes[i]
35 if (c === 0) style = {}
36 else if (c === 1) style = { ...style, bold: true }
37 else if (c === 22) style = { ...style, bold: false }
38 else if (c === 39) style = { ...style, color: undefined }
39 else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) style = { ...style, color: BASIC[c % 10] }
40 else if (c === 38 && codes[i + 1] === 5) {
41 style = { ...style, color: color256(codes[i + 2]) }
42 i += 2
43 } else if (c === 38 && codes[i + 1] === 2) {
44 style = { ...style, color: `#${hex(codes[i + 2])}${hex(codes[i + 3])}${hex(codes[i + 4])}` }
45 i += 4
46 }
47 }
48 }
49
50 push(text.slice(last))
51 return runs
52}
53
54const plain = runs => runs.map(r => r.text).join('').trim()
55
56const run = async ($, argv, cwd) => {
57 try {
58 const r = await $.process.run(argv, { cwd })
59 return r.exitCode === 0 ? r.stdout.trim() : ''
60 } catch {
61 return ''
62 }
63}
64
65// The real starship prompt; [] when starship is missing or fails.
66const starship = async ($, cwd) => {
67 try {
68 const r = await $.process.run(['starship', 'prompt', '--terminal-width', '120'], {
69 cwd,
70 env: { STARSHIP_SHELL: '', PWD: cwd },
71 })
72 if (r.exitCode !== 0) return []
73
74 return r.stdout
75 .split('\n')
76 .map(parseAnsi)
77 .filter(runs => plain(runs) !== '' && plain(runs) !== '❯')
78 } catch {
79 return []
80 }
81}
82
83const shortDir = (cwd, home) => {
84 const p = home && cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd
85 const parts = p.split('/').filter(Boolean)
86 return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : p
87}
88
89// A starship-like line from what the session can read itself.
90const builtin = async ($, cwd) => {
91 const home = await $.env.get('HOME')
92 const segs = [{ text: shortDir(cwd, home), color: 'cyan', bold: true }]
93
94 const branch = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
95 if (branch) {
96 const dirty = await run($, ['git', 'status', '--porcelain'], cwd)
97 segs.push({ text: `on ${branch}${dirty ? ' *' : ''}`, color: 'magenta', bold: true })
98 }
99
100 const hasPython = await $.fs.stat(`${cwd}/pyproject.toml`).then(() => true, () => false)
101 const venvPath = await $.env.get('VIRTUAL_ENV')
102 if (hasPython || venvPath) {
103 const v = (await run($, ['python3', '--version'], cwd)).replace('Python ', '')
104 const venv = venvPath ? venvPath.split('/').pop() : ''
105 const name = venv === '.venv' ? cwd.split('/').pop() : venv
106 segs.push({ text: `via 🐍 v${v}${name ? ` (${name})` : ''}`, color: 'yellow', bold: true })
107 }
108
109 const profile = await $.env.get('AWS_PROFILE')
110 const region = (await $.env.get('AWS_REGION')) ?? (await $.env.get('AWS_DEFAULT_REGION'))
111 if (profile || region) {
112 const text = `on ☁️ ${profile ? profile + ' ' : ''}${region ? `(${region})` : ''}`.trim()
113 segs.push({ text, color: 'yellow', bold: true })
114 }
115
116 return [segs.map((s, i) => ({ ...s, text: (i ? ' ' : '') + s.text }))]
117}
118
119const promptLines = async ($, source) => {
120 const cwd = await $.session.cwd()
121
122 if (source !== 'builtin') {
123 const lines = await starship($, cwd)
124 if (lines.length || source === 'starship') return lines
125 }
126
127 return builtin($, cwd)
128}
129
130const compact = n => {
131 if (n === undefined) return '?'
132 if (n >= 1e6) return `${(n / 1e6).toFixed(n % 1e6 ? 1 : 0)}M`
133 if (n >= 1e3) return `${Math.round(n / 1e3)}k`
134 return String(n)
135}
136
137const LABELS = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
138
139const resetAt = iso => {
140 const d = iso ? new Date(iso) : null
141 if (!d || Number.isNaN(d.getTime())) return ''
142 const hh = String(d.getHours()).padStart(2, '0')
143 const mm = String(d.getMinutes()).padStart(2, '0')
144 return ` ↻${hh}:${mm}`
145}
146
147const usageLine = (model, context, rateLimits) => {
148 const pct = context.percent === undefined ? '' : ` ${context.percent}%`
149 const out = [model, `ctx ${compact(context.tokens)}/${compact(context.window)}${pct}`]
150
151 if (rateLimits?.length) {
152 for (const r of rateLimits) {
153 out.push(`${LABELS[r.kind] ?? r.kind} ${r.percentUsed}%${resetAt(r.resetsAt)}`)
154 }
155 } else {
156 out.push('limits n/a')
157 }
158
159 return out
160}
161
162const refresh = async ($, source) => {
163 const [model, usage, lines] = await Promise.all([
164 $.session.model(),
165 $.session.usage(),
166 promptLines($, source),
167 ])
168 await update($, promptRuns, () => lines)
169 await update($, usageSegs, () => usageLine(model, usage.context, usage.rateLimits))
170}
171
172export function register(on, options) {
173 const source = options?.prompt ?? 'auto'
174
175 on('session.start', async ($, e, next) => {
176 await refresh($, source).catch(() => {})
177 return next(e)
178 })
179
180 on('turn.complete', async ($, e, next) => {
181 await refresh($, source).catch(() => {})
182 return next(e)
183 })
184
185 // Pushed when context moves or a rate-limit window moves a point.
186 on('session.measure', async ($, e, next) => {
187 const model = await $.session.model()
188 await update($, usageSegs, () => usageLine(model, e.context, e.rateLimits))
189 return next(e)
190 })
191
192 // /model changes the main loop's model between turns.
193 on('prompt.submit', async ($, e, next) => {
194 await refresh($, source).catch(() => {})
195 return next(e)
196 })
197
198 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
199 const lines = await read($, promptRuns)
200 const usage = await read($, usageSegs)
201 if (!lines.length && !usage.length) return next(e)
202
203 const { Box, Text } = $.ui.resolve(e)
204
205 return (
206 <Box flexDirection="column">
207 {lines.map((runs, i) => (
208 <Box key={`l${i}`}>
209 {runs.map((r, j) => (
210 <Text key={String(j)} color={r.color} bold={r.bold}>
211 {r.text}
212 </Text>
213 ))}
214 </Box>
215 ))}
216 {usage.length ? (
217 <Box key="usage">
218 <Text dimColor>{usage.join(' · ')}</Text>
219 </Box>
220 ) : null}
221 </Box>
222 )
223 })
224}
225types/index.d.ts 8 lines1export type Run = { text: string; color?: string; bold?: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-bar': { prompt: Run[][]; usage: string[] }
6 }
7}
8