Session stats above the prompt: branch and session name, then cards for model, effort, context, startup, limits and cost

Claude Code mods I made for my own setup.

/rename or --name. After a /rename the name updates on your next prompt/cost. On Max it's just for referenceEverything refreshes every 5 seconds and after each turn. When the terminal is narrow the cards wrap to the next line and keep their width.
Every card, the branch and the session name can be turned off in /config, they all start on. The colors come from a palette you pick there too:
A startup past 20% and a monthly limit past 85% stay red in every palette.
Type this at the prompt of a Claude Code terminal session:
/plugin install session-cards --marketplace alan-oliv/claude-mods
Answer y to add the marketplace and pick a scope. It starts working right away.
Open /config and type session-cards to filter the list:
╭─────────────────────────────────────────────────╮
│ ⌕ session-cards │
╰─────────────────────────────────────────────────╯
❯ Palette · session-cards colorful
Show branch · session-cards true
Show session name · session-cards true
Show model card · session-cards true
Show effort card · session-cards true
Show context card · session-cards true
Show startup card · session-cards true
Show 5h limit card · session-cards true
Show weekly limit card · session-cards true
Show monthly limit card · session-cards true
Show cost card · session-cards true
Enter/Space to change · / to search · Esc to close
Move to a row and press Enter or Space to switch it between true and false, or to pick the palette. It applies right away, no restart needed.
Mods are early access in Claude Code and I built this one on 2.1.296, so a new release can break it. If something looks off, open an issue and I'll take a look.
Context and limits stay empty until the session gets its first reply from the API, there is nothing to read before that.
hooks/register.tsx 291 lines1import { atom, read, update } from 'claude-code'
2import type { ContextCategory, EngineInterface, Register, SessionRateLimit, Settings } from 'claude-code'
3
4import type { DiffStat, LimitWindow, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'session-cards', key: 'snapshot' } as const, null)
7
8const REFRESH_MS = 5000
9const PADDING_X = 1
10const MAX_VALUE = 24
11const CARD_BG = '#2a2927'
12const LABEL = '#9d9db1'
13const VALUE = '#ffffff'
14const LINE = '#e5e6f2'
15const BLUE = '#6eb3ff'
16const PURPLE = '#c394ff'
17const GREEN = '#72c590'
18const YELLOW = '#f3d066'
19const ORANGE = '#fbaa60'
20const RED = '#f47067'
21const TRACK = '#3c3d4e'
22const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
23
24// ok, warn and bad color the startup and monthly cards by how full they are.
25type Palette = { model: string; effort: string; context: string; fiveHour: string; weekly: string; ok: string; warn: string; bad: string; branch: string; added: string; removed: string }
26export const PALETTES: Record<string, Palette> = {
27 colorful: { model: BLUE, effort: PURPLE, context: GREEN, fiveHour: YELLOW, weekly: ORANGE, ok: GREEN, warn: YELLOW, bad: RED, branch: GREEN, added: GREEN, removed: RED },
28 muted: { model: '#8fa3b8', effort: '#a89bba', context: '#8fb39d', fiveHour: '#c2b48a', weekly: '#c2a48a', ok: '#8fb39d', warn: '#c2b48a', bad: '#c08a86', branch: '#8fb39d', added: '#8fb39d', removed: '#c08a86' },
29 mono: { model: LINE, effort: LINE, context: LINE, fiveHour: LINE, weekly: LINE, ok: LINE, warn: LINE, bad: RED, branch: LINE, added: LINE, removed: LINE },
30 'mono-diff': { model: LINE, effort: LINE, context: LINE, fiveHour: LINE, weekly: LINE, ok: LINE, warn: LINE, bad: RED, branch: LINE, added: '#8fb39d', removed: '#c08a86' },
31}
32// Share of the window, in percent, where the startup turns heavy and where it is too much (a full bar).
33const STARTUP_HEAVY = 10
34const STARTUP_TOO_MUCH = 20
35// Percent of the monthly spend limit where the card turns yellow, then red.
36const LIMIT_WARN = 60
37const LIMIT_DANGER = 85
38
39// progress (0 to 1) turns the top line into a bar.
40type Card = { option: string; label: string; value: string; line: string; valueColor?: string; progress?: number }
41
42export function displayModel(id: string): string {
43 const match = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
44 if (!match) return id
45 const [, family = '', major, minor] = match
46 return `${family[0]?.toUpperCase()}${family.slice(1)} ${major}.${minor}`
47}
48
49// The session's effort as Claude Code resolves it: the model's own setting, else the global one. /effort saves here.
50export function resolveEffort(settings: Settings, model: string): string | null {
51 const perModel = (settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined)?.[model]?.effortLevel
52 const effort = perModel ?? settings.effortLevel
53 return typeof effort === 'string' ? effort : null
54}
55
56export function compactTokens(n: number): string {
57 if (n >= 1e6) return `${Number((n / 1e6).toFixed(1))}M`
58 if (n >= 1e3) return `${Math.round(n / 1e3)}k`
59 return String(n)
60}
61
62export function duration(ms: number): string {
63 const minutes = Math.floor(ms / 60_000)
64 if (minutes < 1) return '<1m'
65 const hours = Math.floor(minutes / 60)
66 if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`
67 return hours ? `${hours}h ${minutes % 60}m` : `${minutes}m`
68}
69
70export function limitValue(window: LimitWindow | null): string {
71 if (window === null) return '—'
72 return window.resetIn === null ? `${window.percent}%` : `${window.percent}% · ${window.resetIn}`
73}
74
75export function effortProgress(effort: string | null): number {
76 return (EFFORTS.indexOf(effort ?? '') + 1) / EFFORTS.length
77}
78
79export function startupColor(percent: number, p: Palette): string {
80 if (percent >= STARTUP_TOO_MUCH) return p.bad
81 return percent >= STARTUP_HEAVY ? p.warn : p.ok
82}
83
84export function limitColor(percent: number, p: Palette): string {
85 if (percent >= LIMIT_DANGER) return p.bad
86 return percent >= LIMIT_WARN ? p.warn : p.ok
87}
88
89// Messages is the conversation; every other row in use was there before the first prompt.
90export function startupTokens(categories: readonly ContextCategory[]): number {
91 return categories.filter(c => c.kind === 'used' && c.name !== 'Messages').reduce((sum, c) => sum + c.tokens, 0)
92}
93
94// Binary files read "-" for both counts.
95export function diffStat(numstat: string): DiffStat {
96 const rows = numstat.split('\n').filter(Boolean).map(line => line.split('\t'))
97 return {
98 files: rows.length,
99 added: rows.reduce((sum, [added]) => sum + (Number(added) || 0), 0),
100 removed: rows.reduce((sum, [, removed]) => sum + (Number(removed) || 0), 0),
101 }
102}
103
104export function filledCells(progress: number, width: number): number {
105 return Math.min(width, Math.max(0, Math.round(progress * width)))
106}
107
108function cards(s: Snapshot, p: Palette): Card[] {
109 const startupPercent = ((s.startup ?? 0) / s.context.window) * 100
110 const monthlyPercent = s.limits.monthly?.percent ?? 0
111 return [
112 { option: 'showModel', label: 'model', value: displayModel(s.model), line: p.model, valueColor: p.model },
113 { option: 'showEffort', label: 'effort', value: s.effort ?? '—', line: p.effort, valueColor: p.effort, progress: effortProgress(s.effort) },
114 {
115 option: 'showContext',
116 label: 'context',
117 value: `${compactTokens(s.context.tokens)}/${compactTokens(s.context.window)}, ${s.context.percent}%`,
118 line: p.context,
119 valueColor: p.context,
120 progress: s.context.tokens / s.context.window,
121 },
122 {
123 option: 'showStartup',
124 label: 'startup',
125 value: s.startup === null ? '—' : `${compactTokens(s.startup)}, ${Math.round(startupPercent)}%`,
126 line: startupColor(startupPercent, p),
127 valueColor: startupColor(startupPercent, p),
128 progress: startupPercent / STARTUP_TOO_MUCH,
129 },
130 { option: 'showFiveHourLimit', label: '5h limit', value: limitValue(s.limits.fiveHour), line: p.fiveHour, valueColor: p.fiveHour, progress: (s.limits.fiveHour?.percent ?? 0) / 100 },
131 {
132 option: 'showWeeklyLimit',
133 label: 'weekly limit',
134 value: limitValue(s.limits.sevenDay),
135 line: p.weekly,
136 valueColor: p.weekly,
137 progress: (s.limits.sevenDay?.percent ?? 0) / 100,
138 },
139 {
140 option: 'showMonthlyLimit',
141 label: 'monthly limit',
142 value: limitValue(s.limits.monthly),
143 line: limitColor(monthlyPercent, p),
144 valueColor: limitColor(monthlyPercent, p),
145 progress: monthlyPercent / 100,
146 },
147 { option: 'showCost', label: 'cost', value: s.costUsd === null ? '—' : `$${s.costUsd.toFixed(2)}`, line: LINE },
148 ]
149}
150
151function clip(value: string): string {
152 return value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE - 1)}…` : value
153}
154
155function limitWindow(limit: SessionRateLimit | undefined, now: number): LimitWindow | null {
156 if (!limit) return null
157 return {
158 percent: Math.round(limit.percentUsed),
159 resetIn: limit.resetsAt ? duration(Date.parse(limit.resetsAt) - now) : null,
160 }
161}
162
163async function readSnapshot($: EngineInterface): Promise<Snapshot> {
164 const [head, diff, model, settings, usage, now] = await Promise.all([
165 $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD']),
166 // Without --no-optional-locks, diff rewrites the index every refresh and can collide with the user's own git.
167 $.process.run(['git', '--no-optional-locks', 'diff', 'HEAD', '--numstat']),
168 $.session.model(),
169 $.settings.read(),
170 $.session.usage(),
171 $.clock.now(),
172 ])
173 // Empty until an API response reports the windows, and off a subscription.
174 const fiveHour = usage.rateLimits.find(limit => limit.kind === 'five_hour')
175 const sevenDay = usage.rateLimits.find(limit => limit.kind === 'seven_day')
176 const monthly = usage.rateLimits.find(limit => limit.kind === 'spend_limit')
177 return {
178 title,
179 branch: head.exitCode === 0 ? head.stdout.trim() : null,
180 changes: diff.exitCode === 0 ? diffStat(diff.stdout) : null,
181 model,
182 effort: resolveEffort(settings, model),
183 // tokens and percent are absent until the window's first response.
184 context: { tokens: usage.context.tokens ?? 0, window: usage.context.window, percent: usage.context.percent ?? 0 },
185 limits: { fiveHour: limitWindow(fiveHour, now), sevenDay: limitWindow(sevenDay, now), monthly: limitWindow(monthly, now) },
186 costUsd: usage.cost?.usd ?? null,
187 startup,
188 }
189}
190
191// Measured per turn, not on the timer: even the summary breakdown sizes every tool, skill and memory file.
192async function measureStartup($: EngineInterface) {
193 const { context } = await $.session.usage({ breakdown: 'summary' })
194 startup = context.breakdown ? startupTokens(context.breakdown.categories) : null
195}
196
197// Only classic hook inputs carry the name (/rename or --name), so a rename shows from the next prompt.
198let title: string | null = null
199let startup: number | null = null
200let shown = ''
201
202async function refresh($: EngineInterface) {
203 const next = await readSnapshot($)
204 const key = JSON.stringify(next)
205 if (key === shown) return
206 shown = key
207 await update($, snapshot, () => next)
208}
209
210export const register: Register = (on, options) => {
211 on('session.start', async ($, e, next) => {
212 // A reload starts the module over and classic.SessionStart does not fire again, so take the name from the last snapshot.
213 title ??= (await read($, snapshot))?.title ?? null
214 $.clock.every(REFRESH_MS, () => void refresh($))
215 await measureStartup($)
216 await refresh($)
217 return next(e)
218 })
219
220 on('classic.SessionStart', async ($, e, next) => {
221 title = e.session_title ?? null
222 return next(e)
223 })
224
225 on('classic.UserPromptSubmit', async ($, e, next) => {
226 title = e.session_title ?? null
227 return next(e)
228 })
229
230 on('turn.complete', async ($, e, next) => {
231 await measureStartup($)
232 await refresh($)
233 return next(e)
234 })
235
236 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
237 const s = await read($, snapshot)
238 if (e.props.hasSurvey || s === null) return next(e)
239
240 const { Box, Text } = $.ui.resolve(e)
241 const palette = PALETTES[String(options.palette)] ?? PALETTES.colorful!
242 const shownCards = cards(s, palette).filter(card => options[card.option]).map(card => {
243 const value = clip(card.value)
244 return { ...card, value, width: Math.max(card.label.length, value.length) + 2 * PADDING_X }
245 })
246 const branch = options.showBranch ? s.branch : null
247 const session = options.showSession ? s.title : null
248 return (
249 <Box flexDirection="column" width={e.props.bodyColumns}>
250 {(branch || session) && (
251 <Box marginTop={2} columnGap={1}>
252 {branch && (
253 <Box backgroundColor={CARD_BG} paddingX={PADDING_X} flexShrink={0}>
254 <Text color={palette.branch} backgroundColor={CARD_BG}>{`⎇ ${branch}`}</Text>
255 </Box>
256 )}
257 {branch && s.changes && (
258 <Box backgroundColor={CARD_BG} paddingRight={PADDING_X} flexShrink={0}>
259 <Text color={LABEL} backgroundColor={CARD_BG}>{`🗋 ${s.changes.files} • `}</Text>
260 <Text color={palette.added} backgroundColor={CARD_BG}>{`+${s.changes.added} `}</Text>
261 <Text color={palette.removed} backgroundColor={CARD_BG}>{`-${s.changes.removed}`}</Text>
262 </Box>
263 )}
264 {session && (
265 <Box>
266 <Text color={LABEL}>session: </Text>
267 <Text color={VALUE} wrap="truncate-end">{session}</Text>
268 </Box>
269 )}
270 </Box>
271 )}
272 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
273 {shownCards.map(({ label, value, width, line, valueColor = VALUE, progress = 1 }) => (
274 <Box flexDirection="column" width={width}>
275 {/* A bottom-eighth bar in the row above sits right on the card's top edge. */}
276 <Box flexDirection="row">
277 <Text color={line}>{'▁'.repeat(filledCells(progress, width))}</Text>
278 <Text color={TRACK}>{'▁'.repeat(width - filledCells(progress, width))}</Text>
279 </Box>
280 <Box flexDirection="column" backgroundColor={CARD_BG} paddingX={PADDING_X} paddingY={1}>
281 <Text color={LABEL} backgroundColor={CARD_BG}>{label}</Text>
282 <Text color={valueColor} backgroundColor={CARD_BG}>{value}</Text>
283 </Box>
284 </Box>
285 ))}
286 </Box>
287 </Box>
288 )
289 })
290}
291types/index.d.ts 23 lines1// resetIn is stored formatted, so a card redraws once a minute rather than on every refresh.
2export type LimitWindow = { percent: number; resetIn: string | null }
3
4export type DiffStat = { files: number; added: number; removed: number }
5
6export type Snapshot = {
7 title: string | null
8 branch: string | null
9 changes: DiffStat | null
10 model: string
11 effort: string | null
12 context: { tokens: number; window: number; percent: number }
13 limits: { fiveHour: LimitWindow | null; sevenDay: LimitWindow | null; monthly: LimitWindow | null }
14 costUsd: number | null
15 startup: number | null
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'session-cards': { snapshot: Snapshot | null }
21 }
22}
23