SLOPSHOPPER

session-cards

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

newbandprocesstimer
★ 1v0.2.6no licenseupdated 2026-10-15alan-oliv/claude-mods/session-cards
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-cards
› 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 ⎇ feat/auth-refresh 🗋 0 • +0 -0 ▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁ model effort context startup 5h limit weekly limit monthly limit cost Opus 5.5 — 97k/200k, 49% — 31% · NaNm — — $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⎇ feat/auth-refresh ▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▁▁▁▁▁▁▁ model effort context startup 5h limit weekly limit monthly limit cost Opus 5.5 — 97k/200k, 49% — 31% · NaNm — — $0.42
README

claude-mods

Claude Code mods I made for my own setup.


Session stats

session-cards above the Claude Code prompt

  • branch and session sit on one line above the cards. branch is a badge with the current git branch, next to a badge with the files and lines changed since the last commit. session is the name from /rename or --name. After a /rename the name updates on your next prompt
  • model is the session model
  • effort is the effort level from your settings, the model setting first and then the global one. The bar fills by level, from low to max
  • context is how full the context window is, the top line works as a progress bar
  • startup is how much of the context window the session takes before your first prompt: system prompt, tools, MCP, skills, agents and memory files. It's measured again after each turn, so an MCP server that connects late shows up. It's green under 10% of the window, yellow up to 20% and red past that, when the bar is full
  • 5h limit is how much of the 5 hour window you used and when it resets, with a bar too
  • weekly limit is the same for the weekly window
  • monthly limit is how much of your monthly spend limit you used and when it resets. The bar fills with usage and goes green, then yellow from 60% and red from 85%. It only has a value where a spend limit is set
  • cost is the session cost in dollars, same number as /cost. On Max it's just for reference

Everything 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:

  • colorful is a color per card, the default
  • muted is the same colors toned down
  • mono is one light grey for everything
  • mono-diff is mono with a muted green and red on the file changes badge

A startup past 20% and a monthly limit past 85% stay red in every palette.

Install

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.

Turning cards off and picking a palette

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.

Notes

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.

Source 2 files
hooks/register.tsx 291 lines
1import { 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}
291
types/index.d.ts 23 lines
1// 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