SLOPSHOPPER

session-bar

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

newbandpromptprocess
v0.1.0MITupdated 2026-10-16sourabp/claude-session-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-bar
› 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 /work/app on feat/auth-refresh * claude-opus-5-5 · ctx 97k/200k 49% · 5h 31% ↻09:53 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
/work/app on feat/auth-refresh * claude-opus-5-5 · ctx 97k/200k 49% · 5h 31% ↻09:53
README

claude-session-bar

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
  • Top row: your real starship prompt, colors included. Without starship, a built-in line shows the directory, git branch and dirty flag, Python version and venv, and AWS profile/region.
  • Bottom row: current model, context-window fill, and the account's rate-limit windows (5-hour, 7-day) with reset times.

Install

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.

Configuration

Run /config and find the Prompt source row for this plugin (or set pluginConfigs["session-bar"].options.prompt in settings.json).

ValueBehavior
auto (default)Use starship if it is installed and works, otherwise the built-in line.
starshipStarship only. The top row is empty if starship is missing.
builtinNever run starship.

Starship reads your usual ~/.config/starship.toml.

Notes

  • Requires Claude Code v2.1.287 or later. The mod API is early access and may change between releases.
  • The band refreshes on session start, on prompt submit and when a turn completes, not on every keystroke. A cd made inside Claude's shell shows up after the turn.
  • Environment-based segments (venv, AWS) read the environment Claude Code started in. Activating a venv inside Claude's shell does not change them.
  • 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.
  • Security: mods run with your full permissions and are not sandboxed. Read the code before installing. This one runs 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.

Development

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.

License

MIT

Source 2 files
hooks/register.jsx 225 lines
1import { 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}
225
types/index.d.ts 8 lines
1export 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