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

My Claude Code mods, as a plugin marketplace.
| Mod | What it does |
|---|---|
session-bar | A band above the prompt: folder, git branch, model, context-window %, session cost and plan usage limits |
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.
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>.
hooks/register.tsx 115 lines1import { 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}
115hooks/format.ts 63 lines1import 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}
63types/index.d.ts 16 lines1export 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