SLOPSHOPPER

session-bar

Folder, branch, model, context %, cost and plan limits in a band above the prompt

bandstatusprocesstimer
v0.2.0no licenseupdated 2026-10-17Ryan-ED/claude-mods/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 app │ Opus 5.5 │ ctx ▓▓▓▓▓░░░░░ 49% 97k/200k │ $0.42 │ 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app │ Opus 5.5 │ ctx ▓▓▓▓▓░░░░░ 49% 97k/200k │ $0.42 │ 5h 31%
README

claude-mods

My Claude Code mods, as a plugin marketplace.

ModWhat it does
session-barA band above the prompt: folder, git branch, model, context-window %, session cost and plan usage limits

Install

At a Claude Code prompt (terminal):

/plugin install session-bar --marketplace Ryan-ED/claude-mods

Answer y to add the marketplace, then pick the user scope. The repo is private, so the machine's git needs access to it (e.g. gh auth login).

Update later with claude plugin update session-bar.

Developing

Edit a mod's folder, then run /reload-plugins in a session that installed it from this folder (claude plugin marketplace add ~/.claude/mods). Check a mod with claude plugin validate <folder> and claude plugin test <folder>.

Source 3 files
hooks/register.tsx 115 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { placeName, segments } from './format'
6
7const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0' }
8const GIT_TIMEOUT_MS = 3000
9const POLL_MS = 10_000
10// session.measure can fire several times a turn; git is re-read at most this often.
11const GIT_MIN_INTERVAL_MS = 3000
12
13const snapshot = atom({ plugin: 'session-bar', key: 'snapshot' } as const, {} as Snapshot)
14
15let gitInFlight: Promise<void> | undefined
16let gitReadAt = -Infinity
17
18function git($: EngineInterface, cwd: string, args: string[]) {
19  return $.process.run(['git', ...args], { cwd, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS })
20}
21
22async function readGit($: EngineInterface) {
23  const cwd = await $.session.cwd()
24  const head = await git($, cwd, ['rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD']).catch(() => undefined)
25  if (!head || head.exitCode !== 0) {
26    await update($, snapshot, s => ({ ...s, place: placeName(cwd), branch: undefined, isDirty: undefined }))
27    return
28  }
29  const [top, ref] = head.stdout.trim().split('\n')
30  const [branch, status] = await Promise.all([
31    ref === 'HEAD'
32      ? git($, cwd, ['rev-parse', '--short', 'HEAD']).then(r => `@${r.stdout.trim()}`, () => 'HEAD')
33      : ref,
34    git($, cwd, ['status', '--porcelain', '--ignore-submodules']).catch(() => undefined),
35  ])
36  await update($, snapshot, s => ({
37    ...s,
38    place: placeName(cwd, top),
39    branch,
40    isDirty: status ? status.stdout.trim() !== '' : undefined,
41  }))
42}
43
44// One git read at a time, and none within GIT_MIN_INTERVAL_MS of the last.
45async function refreshGit($: EngineInterface) {
46  if (gitInFlight) return gitInFlight
47  const now = await $.clock.now()
48  if (now - gitReadAt < GIT_MIN_INTERVAL_MS) return
49  gitReadAt = now
50  gitInFlight = readGit($).finally(() => {
51    gitInFlight = undefined
52  })
53  return gitInFlight
54}
55
56async function refreshModel($: EngineInterface) {
57  const model = await $.session.model().catch(() => undefined)
58  if (model !== undefined) await update($, snapshot, s => (s.model === model ? s : { ...s, model }))
59}
60
61async function refresh($: EngineInterface) {
62  await Promise.all([refreshGit($), refreshModel($)])
63}
64
65const TONE_COLOR = { warning: 'warning', error: 'error' } as const
66
67export const register: Register = on => {
68  on('session.start', async ($, e, next) => {
69    const started = await next(e)
70    $.ui.status(undefined) // clear the status line left by v0.1
71    const usage = await $.session.usage().catch(() => undefined)
72    if (usage) {
73      await update($, snapshot, s => ({
74        ...s,
75        context: usage.context,
76        costUsd: usage.cost?.usd,
77        limits: usage.rateLimits,
78      }))
79    }
80    await refresh($)
81    $.clock.every(POLL_MS, () => {
82      void refresh($)
83    })
84    return started
85  })
86
87  on('session.measure', async ($, e, next) => {
88    const measured = await next(e)
89    await update($, snapshot, s => ({ ...s, context: e.context, costUsd: e.cost?.usd, limits: e.rateLimits }))
90    await refresh($)
91    return measured
92  })
93
94  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
95    if (e.props.hasSurvey) return next(e)
96    const segs = segments(await read($, snapshot))
97    if (segs.length === 0) return next(e)
98
99    const { Text } = $.ui.resolve(e)
100    return (
101      <Text wrap="truncate-end">
102        {segs.map(seg =>
103          seg.tone === 'dim' ? (
104            <Text dimColor>{seg.text}</Text>
105          ) : seg.tone ? (
106            <Text color={TONE_COLOR[seg.tone]}>{seg.text}</Text>
107          ) : (
108            <Text>{seg.text}</Text>
109          ),
110        )}
111      </Text>
112    )
113  })
114}
115
hooks/format.ts 63 lines
1import type { Snapshot } from '../types'
2
3/** A run of text in the band; `tone` colours it, left out it is plain. */
4export type Segment = { text: string; tone?: 'dim' | 'warning' | 'error' }
5
6const SEP: Segment = { text: ' │ ', tone: 'dim' }
7const BAR_CELLS = 10
8const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
9
10const kilo = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
11
12export const prettyModel = (model: string) => {
13  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/.exec(model)
14  if (!m) return model
15  const [, family = '', major, minor, oneM] = m
16  const name = family.charAt(0).toUpperCase() + family.slice(1)
17  return `${name} ${major}${minor ? `.${minor}` : ''}${oneM ? ' 1M' : ''}`
18}
19
20export const contextSegments = (context: NonNullable<Snapshot['context']>): Segment[] => {
21  if (context.percent === undefined) return [{ text: 'ctx ', tone: 'dim' }, { text: `—% of ${kilo(context.window)}` }]
22  const pct = Math.max(0, Math.min(100, context.percent))
23  const filled = Math.round((pct / 100) * BAR_CELLS)
24  const tone = pct >= 85 ? 'error' : pct >= 70 ? 'warning' : undefined
25  const tokens = context.tokens === undefined ? '' : ` ${kilo(context.tokens)}/${kilo(context.window)}`
26  return [
27    { text: 'ctx ', tone: 'dim' },
28    { text: '▓'.repeat(filled), tone },
29    { text: '░'.repeat(BAR_CELLS - filled), tone: 'dim' },
30    { text: ` ${pct}%`, tone },
31    { text: tokens, tone: 'dim' },
32  ]
33}
34
35export const limitsText = (limits: NonNullable<Snapshot['limits']>) =>
36  limits.map(l => `${LIMIT_LABELS[l.kind] ?? l.kind} ${Math.round(l.percentUsed)}%`).join(' · ')
37
38export const segments = (s: Snapshot): Segment[] => {
39  const groups: Segment[][] = []
40  const where: Segment[] = []
41  if (s.place) where.push({ text: s.place })
42  if (s.branch) where.push({ text: `${where.length ? ' ' : ''}⎇ ${s.branch}` }, ...(s.isDirty ? [{ text: '*', tone: 'warning' } as const] : []))
43  if (where.length) groups.push(where)
44  if (s.model) groups.push([{ text: prettyModel(s.model) }])
45  if (s.context) groups.push(contextSegments(s.context))
46  if (s.costUsd !== undefined) groups.push([{ text: `$${s.costUsd.toFixed(2)}` }])
47  if (s.limits?.length) groups.push([{ text: limitsText(s.limits) }])
48  return groups.flatMap((g, i) => (i === 0 ? g : [SEP, ...g])).filter(seg => seg.text !== '')
49}
50
51/** The band as plain text. */
52export const format = (s: Snapshot) => segments(s).map(seg => seg.text).join('')
53
54/** `repo`, `repo/sub` or `repo/…/leaf` for where cwd sits under the repo's top level. */
55export const placeName = (cwd: string, top?: string) => {
56  const base = (p: string) => p.replace(/\/+$/, '').split('/').pop() || p
57  if (!top) return base(cwd)
58  const rest = cwd.startsWith(top + '/') ? cwd.slice(top.length + 1).split('/').filter(Boolean) : []
59  if (rest.length === 0) return base(top)
60  if (rest.length === 1) return `${base(top)}/${rest[0]}`
61  return `${base(top)}/…/${rest[rest.length - 1]}`
62}
63
types/index.d.ts 16 lines
1export type Snapshot = {
2  place?: string
3  branch?: string
4  isDirty?: boolean
5  model?: string
6  context?: { tokens?: number; window: number; percent?: number }
7  costUsd?: number
8  limits?: readonly { kind: string; percentUsed: number }[]
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'session-bar': { snapshot: Snapshot }
14  }
15}
16