SLOPSHOPPER

session-status

Band above the prompt: prompt-cache countdown, context fill, session cost, usage limits and a Compact button.

newbandcommandtoasttimer
v0.1.2MITupdated 2026-10-14sphings79/claude-mods/session-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-status
› 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 › /session-status ⎿ session-status: Status band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods: Claude Code mods

Mods for Claude Code: a status band above the prompt with prompt-cache countdown, context fill, usage limits and session cost.

English | Deutsch

If this is useful, a ⭐ on the repo helps others find it.

ModWhat it does
session-statusBand above the prompt: prompt-cache countdown, context fill, usage limits (5-hour and weekly), session cost, Compact button

session-status: Claude Code band above the prompt showing prompt-cache countdown, context fill, 5-hour and weekly usage limits, session cost and a Compact button

Install

Needs a Claude Code build with mod (function hook) support. Developed against 2.1.295.

Or just ask Claude in any Claude Code chat, including the desktop app:

Install the plugin session-status from https://github.com/sphings79/claude-mods for all my sessions (user scope).

Claude adds the marketplace and installs the plugin for you. Read the code first if you like; it is a few hundred lines.

In a Claude Code terminal session:

/plugin install session-status --marketplace sphings79/claude-mods

Answer y to add the marketplace, then pick a scope. User scope loads it in every session.

Or from a shell:

claude plugin marketplace add sphings79/claude-mods
claude plugin install session-status@claude-mods

No SSH key set up for GitHub? Add the marketplace over HTTPS instead:

claude plugin marketplace add https://github.com/sphings79/claude-mods.git
claude plugin install session-status@claude-mods

Then load it: restart Claude Code or run /reload-plugins. The band appears after the first answer of a session.

Desktop app: /plugin install is not available in the Code tab. Install from a terminal at user scope; the mod then loads in desktop sessions too.

Update / remove:

claude plugin update session-status@claude-mods
claude plugin uninstall session-status@claude-mods

session-status

Cache    60m ■■■■■■■■■■■■■■■■■■■■ (184k tokens)                  [Compact]
Context  19% ■■■■□□□□□□□□□□□□□□□□ (Auto-compact 97%)
Limits   5h 23% (14:30)  Week 41% (Mon 09:00)               ($1.23)
  • Cache: minutes left on the 1-hour prompt cache since the last answer. The bar shows the time left: green, amber at 20 min, red at 10 min, expired when it is gone. Behind the bar: how many tokens the cache holds (read plus written by the last response).
  • Context: number and bar by context fill, green, amber from 60 %, red from 80 %.
  • Auto-compact: the threshold at which Claude Code compacts on its own, in percent of the window.
  • Limits: usage of the 5-hour and weekly windows with their reset time; a gateway's spend limit too. Colours follow the context thresholds. Shown once the last response reported them.
  • Cost (bottom right, in parentheses): what the session has cost so far, as /cost totals it. On a subscription that is an API-equivalent estimate, not a bill.
  • Compact (top right): runs /compact. Not while an answer is being written.
  • /session-status shows or hides the band.

Language

Option language: auto (default; follows Claude Code's language setting, then the system locale), de or en. Set it in /config, or in ~/.claude/settings.json:

{ "pluginConfigs": { "session-status": { "options": { "language": "de" } } } }

Note: The Claude desktop app passes neither a language setting nor LANG to mods, so auto often falls back to English there even on a German system. Set language explicitly (above). /session-status lang shows what the detection saw.

Support

Free and MIT licensed. If it saves you tokens or time, you can buy me a coffee. More at sphings-dev.de.

License

MIT

Source 3 files
hooks/register.tsx 259 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Cache, Stats } from '../types'
5import { BAR_CELLS, CACHE_TTL_S, NARROW_COLUMNS, TEXT, cacheLevel, cacheTokens, formatTokens, contextCells, contextLevel, filledCells, langOf, limitLabel, minutesLeft, resetLabel, usd } from './logic'
6import type { Lang, Level } from './logic'
7
8const stats = atom({ plugin: 'session-status', key: 'stats' } as const, {
9  turn: 0,
10  context: 0,
11  autoCompact: null,
12  cost: null,
13  cacheTokens: null,
14  limits: [],
15} as Stats)
16
17const cache = atom({ plugin: 'session-status', key: 'cache' } as const, {
18  expiresAt: null,
19  secondsLeft: null,
20} as Cache)
21
22const isHidden = atom({ plugin: 'session-status', key: 'isHidden' } as const, false)
23const isBusy = atom({ plugin: 'session-status', key: 'isBusy' } as const, false)
24// Whether the savvy-progress plugin's `/agents-info` command exists in this session.
25const hasAgents = atom({ plugin: 'session-status', key: 'hasAgents' } as const, false)
26
27const COLORS: Record<Level, string> = { ok: '#4caf6e', warn: '#e2a94e', alert: '#e0846b', expired: '#e0645b' }
28const MUTED = '#8a8f8c'
29
30// The `language` option, else Claude Code's `language` setting, else the locale; English otherwise.
31const detectLang = async ($: EngineInterface, option: unknown): Promise<Lang> => {
32  if (option === 'de' || option === 'en') return option
33  try {
34    const settings = (await $.settings.read()) as Record<string, unknown>
35    if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language)
36  } catch {
37    // No settings: fall through to the locale.
38  }
39  const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG')) || (await $.env.get('LANGUAGE'))
40  if (env) return langOf(env)
41  // The desktop app passes no locale variables: ask the runtime.
42  return langOf(typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().locale : undefined)
43}
44
45const AGENTS_COMMAND = 'agents-info'
46
47const refreshAgents = async ($: EngineInterface) => {
48  const found = await $.command.list().then(list => list.some(c => c.name === AGENTS_COMMAND && c.source === 'plugin'), () => false)
49  if (found !== (await read($, hasAgents))) await update($, hasAgents, () => found)
50}
51
52// Limits belong to the account, but a session only learns them from its own responses.
53// Each session publishes what it measured and picks up what another session published later.
54const SHARED_LIMITS = 'limits'
55type SharedLimits = { at: number; limits: Stats['limits'] }
56let limitsAt = 0
57
58// `fresh`: the limits come from a response this session just had; only then are they published.
59const refreshUsage = async ($: EngineInterface, fresh = false) => {
60  const { context, cost, rateLimits } = await $.session.usage({ breakdown: 'summary' })
61  const threshold = context.breakdown?.autoCompactThreshold
62  const autoCompact = threshold && context.window ? Math.round((threshold / context.window) * 100) : null
63  const limits = rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt ?? null }))
64  if (fresh && limits.length > 0) {
65    limitsAt = await $.clock.now()
66    await $.store.set(SHARED_LIMITS, { at: limitsAt, limits } satisfies SharedLimits).catch(() => undefined)
67  }
68  await update($, stats, s => ({ ...s, context: context.percent ?? s.context, autoCompact, cost: cost?.usd ?? null, limits: fresh ? limits : s.limits }))
69}
70
71const adoptSharedLimits = async ($: EngineInterface) => {
72  const shared = (await $.store.get(SHARED_LIMITS).catch(() => undefined)) as SharedLimits | undefined
73  if (!shared || typeof shared.at !== 'number' || !Array.isArray(shared.limits) || shared.at <= limitsAt) return
74  limitsAt = shared.at
75  await update($, stats, s => ({ ...s, limits: shared.limits }))
76}
77
78export const register: Register = (on, options) => {
79  let lang: Lang = 'en'
80
81  on('session.start', async ($, e, next) => {
82    const result = await next(e)
83    lang = await detectLang($, options.language)
84
85    await $.command.register({ name: 'session-status', description: 'Show or hide the session status band' })
86
87    // Re-derive the countdown; write only when the shown minute or a bar cell changes.
88    $.clock.every(5000, async () => {
89      await refreshAgents($)
90      await adoptSharedLimits($)
91      const { expiresAt, secondsLeft } = await read($, cache)
92      if (expiresAt === null) return
93      const left = Math.max(0, Math.floor((expiresAt - (await $.clock.now())) / 1000))
94      const changed =
95        secondsLeft === null ||
96        minutesLeft(left) !== minutesLeft(secondsLeft) ||
97        filledCells(left) !== filledCells(secondsLeft)
98      if (changed) await update($, cache, c => ({ ...c, secondsLeft: left }))
99    })
100
101    return result
102  })
103
104  on('command.run', { command: 'session-status' }, async ($, e) => {
105    // `/session-status lang` shows what the language detection saw.
106    if (e.args.trim() === 'lang') {
107      const settings = await $.settings.read().then(v => (v as Record<string, unknown>).language, () => undefined)
108      const seen = {
109        option: options.language,
110        settings: settings ?? null,
111        LC_ALL: (await $.env.get('LC_ALL')) ?? null,
112        LC_MESSAGES: (await $.env.get('LC_MESSAGES')) ?? null,
113        LANG: (await $.env.get('LANG')) ?? null,
114        LANGUAGE: (await $.env.get('LANGUAGE')) ?? null,
115        intl: typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().locale : null,
116        used: lang,
117      }
118      return { text: JSON.stringify(seen) }
119    }
120
121    const hidden = !(await read($, isHidden))
122    await update($, isHidden, () => hidden)
123    return { text: hidden ? TEXT[lang].hidden : TEXT[lang].shown }
124  })
125
126  on('turn.start', async ($, e, next) => {
127    await update($, isBusy, () => true)
128    return next(e)
129  })
130
131  // What the cache holds, from the main agent's last response.
132  on('turn.step', async function* ($, e, next) {
133    if (e.agentId !== undefined) return yield* next(e)
134
135    for await (const chunk of next(e)) {
136      if (chunk.kind === 'stop' && chunk.usage !== null) {
137        const tokens = cacheTokens(chunk.usage)
138        if (tokens !== null) await update($, stats, s => ({ ...s, cacheTokens: tokens }))
139      }
140      yield chunk
141    }
142  })
143
144  on('turn.complete', async ($, e, next) => {
145    const result = await next(e)
146
147    if (e.agentId === undefined) {
148      const now = await $.clock.now()
149      await update($, cache, () => ({ expiresAt: now + CACHE_TTL_S * 1000, secondsLeft: CACHE_TTL_S }))
150      await update($, stats, s => ({ ...s, turn: s.turn + 1 }))
151      await update($, isBusy, () => false)
152      await refreshUsage($, true)
153    }
154
155    return result
156  })
157
158  // The engine pushes a measurement when a limit window moves a whole point, also mid-turn.
159  on('session.measure', async ($, e, next) => {
160    const result = await next(e)
161    if (e.changed.includes('rateLimits') || e.changed.includes('context') || e.changed.includes('cost')) {
162      await refreshUsage($, e.changed.includes('rateLimits')).catch(() => undefined)
163    }
164    return result
165  })
166
167  on('session.compact', async ($, e, next) => {
168    const result = await next(e)
169    await refreshUsage($)
170    return result
171  })
172
173  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
174    const s = await read($, stats)
175    if (e.props.hasSurvey || s.turn === 0 || (await read($, isHidden))) return next(e)
176
177    const { Box, Button, Text } = $.ui.resolve(e)
178    const t = TEXT[lang]
179    const { secondsLeft } = await read($, cache)
180    const level = cacheLevel(secondsLeft) ?? 'expired'
181    const color = COLORS[level]
182    const columns = e.props.bodyColumns
183    const now = await $.clock.now()
184    const ctxColor = COLORS[contextLevel(s.context)]
185    const ctxLit = contextCells(s.context)
186    const agentsShown = await read($, hasAgents)
187    const lit = secondsLeft === null ? 0 : filledCells(secondsLeft)
188
189    const cacheLabel = `${minutesLeft(secondsLeft ?? 0)}m`
190
191    // Fixed-width cells keep icon, label, value and bar of both rows in line.
192    const Cell = ({ width, children }: { width: number; children?: unknown }) => (
193      <Box width={width} flexShrink={0}>{children}</Box>
194    )
195
196    return (
197      <Box flexDirection="column" paddingX={1}>
198        <Box flexDirection="row" alignItems="center" columnGap={1} flexWrap="nowrap" overflow="hidden">
199          <Cell width={8}><Text dimColor wrap="truncate-end">{t.cache}</Text></Cell>
200          <Cell width={5}><Text bold color={color} wrap="truncate-end">{cacheLabel}</Text></Cell>
201          {columns >= 50 ? (
202            <Text wrap="truncate-end">
203              <Text color={color}>{'■'.repeat(lit)}</Text>
204              <Text color={MUTED} dimColor>{'■'.repeat(BAR_CELLS - lit)}</Text>
205            </Text>
206          ) : null}
207          {level === 'expired' ? <Text bold color={color} wrap="truncate-end">{columns < NARROW_COLUMNS ? t.expiredShort : t.expired}</Text> : null}
208          {s.cacheTokens !== null ? (
209            <Text dimColor wrap="truncate-end">{`(${formatTokens(s.cacheTokens)} ${t.tokens})`}</Text>
210          ) : null}
211          <Box flexGrow={1} flexShrink={1} />
212          {agentsShown ? (
213            <Button
214              key="agents"
215              label={t.agents}
216              onPress={async () => {
217                await $.command.run({ command: AGENTS_COMMAND }).catch(() => $.ui.toast(t.busy))
218              }}
219            />
220          ) : null}
221          <Button
222            key="compact"
223            label={t.compact}
224            onPress={async () => {
225              if (await read($, isBusy)) return $.ui.toast(t.busy)
226              await $.session.compact().catch(() => $.ui.toast(t.busy))
227            }}
228          />
229        </Box>
230        <Box flexDirection="row" alignItems="center" columnGap={1} flexWrap="nowrap" overflow="hidden">
231          <Cell width={8}><Text dimColor wrap="truncate-end">{t.context}</Text></Cell>
232          <Cell width={5}><Text bold color={ctxColor} wrap="truncate-end">{`${s.context}%`}</Text></Cell>
233          {columns >= 50 ? (
234            <Text wrap="truncate-end">
235              <Text color={ctxColor}>{'■'.repeat(ctxLit)}</Text>
236              <Text color={MUTED} dimColor>{'■'.repeat(BAR_CELLS - ctxLit)}</Text>
237            </Text>
238          ) : null}
239          {s.autoCompact !== null ? <Text dimColor wrap="truncate-end">{`(${t.auto} ${s.autoCompact}%)`}</Text> : null}
240        </Box>
241        {s.limits.length > 0 || s.cost !== null ? (
242          <Box flexDirection="row" alignItems="center" columnGap={1} flexWrap="nowrap" overflow="hidden">
243            {s.limits.length > 0 ? <Cell width={8}><Text dimColor wrap="truncate-end">{t.limits}</Text></Cell> : null}
244            {s.limits.map(l => (
245              <Text key={l.kind} wrap="truncate-end">
246                <Text dimColor>{limitLabel(l.kind, lang)} </Text>
247                <Text bold color={COLORS[contextLevel(l.percent)]}>{`${Math.round(l.percent)}%`}</Text>
248                <Text dimColor>{resetLabel(l.resetsAt, lang, now) ? ` (${resetLabel(l.resetsAt, lang, now)})` : ''}</Text>
249              </Text>
250            ))}
251            <Box flexGrow={1} flexShrink={1} />
252            {s.cost !== null ? <Text dimColor wrap="truncate-end">{`(${usd(s.cost)})`}</Text> : null}
253          </Box>
254        ) : null}
255      </Box>
256    )
257  })
258}
259
hooks/logic.ts 67 lines
1export type Lang = 'de' | 'en'
2
3export const CACHE_TTL_S = 60 * 60
4export const CACHE_WARN_S = 20 * 60
5export const CACHE_ALERT_S = 10 * 60
6export const BAR_CELLS = 20
7// Below this width the full row (bar, `expired`, tokens, button) no longer fits.
8export const NARROW_COLUMNS = 75
9
10export const langOf = (value: string | undefined): Lang => (/^de/i.test((value ?? '').trim()) ? 'de' : 'en')
11
12export type Level = 'ok' | 'warn' | 'alert' | 'expired'
13
14export const cacheLevel = (secondsLeft: number | null): Level | null =>
15  secondsLeft === null
16    ? null
17    : secondsLeft <= 0
18      ? 'expired'
19      : secondsLeft <= CACHE_ALERT_S
20        ? 'alert'
21        : secondsLeft <= CACHE_WARN_S
22          ? 'warn'
23          : 'ok'
24
25// Cells lit in the bar for the time the cache has left.
26export const filledCells = (secondsLeft: number): number =>
27  Math.min(BAR_CELLS, Math.max(0, Math.ceil((secondsLeft / CACHE_TTL_S) * BAR_CELLS)))
28
29// Cells lit in the context bar; at least one as soon as anything is used.
30export const contextCells = (percent: number): number =>
31  percent <= 0 ? 0 : Math.min(BAR_CELLS, Math.max(1, Math.round((percent / 100) * BAR_CELLS)))
32
33// Calm, getting full, nearly full.
34export const contextLevel = (percent: number): Level => (percent >= 80 ? 'alert' : percent >= 60 ? 'warn' : 'ok')
35
36// Tokens the cache holds: what the last response read from it plus what it wrote.
37export const cacheTokens = (usage: { cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): number | null => {
38  const total = (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
39  return total > 0 ? total : null
40}
41
42export const formatTokens = (n: number): string =>
43  n < 1000 ? String(n) : n < 1_000_000 ? `${Math.round(n / 1000)}k` : `${(n / 1_000_000).toFixed(1)}M`
44
45export const usd = (n: number): string => `$${n.toFixed(2)}`
46
47const pad = (n: number) => String(n).padStart(2, '0')
48
49// Label of a rate-limit window: `five_hour`, `seven_day`, a gateway's `spend_limit`.
50export const limitLabel = (kind: string, lang: Lang): string =>
51  kind === 'five_hour' ? '5h' : kind === 'seven_day' ? TEXT[lang].week : kind === 'spend_limit' ? TEXT[lang].spend : kind
52
53// When a window resets: the time today, else weekday and time; '' without a usable timestamp.
54export const resetLabel = (iso: string | null, lang: Lang, now: number): string => {
55  const date = iso === null ? null : new Date(iso)
56  if (date === null || Number.isNaN(date.getTime())) return ''
57  const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
58  return date.toDateString() === new Date(now).toDateString() ? time : `${TEXT[lang].days[date.getDay()]} ${time}`
59}
60
61export const minutesLeft = (secondsLeft: number): number => Math.ceil(secondsLeft / 60)
62
63export const TEXT = {
64  de: { cache: 'Cache', expired: 'abgelaufen', expiredShort: 'abgel.', context: 'Kontext', auto: 'Auto-Compact', compact: 'Compact', agents: 'Agents', tokens: 'Token', limits: 'Limits', week: 'Woche', spend: 'Limit', days: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], busy: 'Compact geht erst nach der Antwort.', shown: 'Status-Leiste eingeblendet.', hidden: 'Status-Leiste ausgeblendet.' },
65  en: { cache: 'Cache', expired: 'expired', expiredShort: 'exp.', context: 'Context', auto: 'Auto-compact', compact: 'Compact', agents: 'Agents', tokens: 'tokens', limits: 'Limits', week: 'Week', spend: 'Limit', days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], busy: 'Compact works once the answer is done.', shown: 'Status band shown.', hidden: 'Status band hidden.' },
66} as const
67
types/index.d.ts 26 lines
1export type Limit = {
2  kind: string
3  percent: number
4  resetsAt: string | null
5}
6
7export type Stats = {
8  turn: number
9  context: number
10  autoCompact: number | null
11  cost: number | null
12  cacheTokens: number | null
13  limits: Limit[]
14}
15
16export type Cache = {
17  expiresAt: number | null
18  secondsLeft: number | null
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'session-status': { stats: Stats; cache: Cache; isHidden: boolean; isBusy: boolean; hasAgents: boolean }
24  }
25}
26