SLOPSHOPPER

context-status

Band above the prompt that always shows the current model, its effort level and context window usage

newbandstatustimer
v0.3.0no licenseupdated 2026-10-10filip-git-git/claude-context-status/context-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 Opus 5.5 · ctx █████░░░░░ 49% · 97.4k/200k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 · ctx █████░░░░░ 49% · 97.4k/200k
README

claude-context-status

A Claude Code mod that keeps the current model, its effort level and the context window usage always visible in a line drawn just above the prompt.

Opus 5.5 · high · ctx ████░░░░░░ 42% · 84k/200k

Features

  • Current model: the main-loop model as /model shows it, with ids like claude-opus-5-5 shortened to Opus 5.5.
  • Effort level: low to max. Before the first turn it comes from your settings (modelSettings.<model>.effortLevel, then effortLevel); after each turn it is the effort the request was actually sent with. Models without an effort setting show none.
  • Context usage: a 10-cell bar, the percentage, and tokens used out of the window size (84k/200k, 1M).
  • Colors by usage: the bar is green below 80%, yellow from 80%, and red with a compact soon note from 95%. Colors follow your Claude Code theme.
  • No extra API traffic: it only reads figures the session already has.

Before the model's first reply in a session, Claude Code has no usage figure yet, so the line shows ctx — · 200k until then.

The line steps aside while Claude Code shows a survey in the same place.

How it updates

The line is refreshed after every turn, whenever Claude Code measures the session, and every 5 seconds. Mods get no event when you switch models with /model or change the effort, so the 5-second check is what picks up a switch between turns. The text is only redrawn when it actually changes.

Requirements

Claude Code with mod (function hooks) support. Developed and tested on 2.1.296.

Installation

In Claude Code (2.1.275 or later), run:

/plugin install context-status --marketplace filip-git-git/claude-context-status

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). The line appears right away.

From a local clone

Clone the repository:

git clone https://github.com/filip-git-git/claude-context-status.git

Start Claude Code with the mod folder:

claude --plugin-dir /path/to/claude-context-status/context-status

To load it in every session without the flag, add the folder to CLAUDE_CODE_PLUGIN_DIRS, either in your environment or in the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-context-status/context-status"
  }
}

Development

context-status/
├── .claude-plugin/plugin.json      manifest
├── hooks/
│   ├── hooks.json                  points at the hooks module
│   ├── register.tsx                event wiring and the band above the prompt
│   ├── effort-level.ts             which effort level to show
│   └── format-status.ts            pure formatting of the line
├── types/index.d.ts                state contract
└── tests/context-status.test.ts

Validate the mod and run the tests:

claude plugin validate context-status
claude plugin test context-status

Thresholds (80% / 95%) and the poll interval (5 s) are constants at the top of format-status.ts and register.tsx.

Source 4 files
hooks/register.tsx 108 lines
1// Draws a band above the prompt with the main-loop model, its effort and context window fill.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, Timer } from 'claude-code'
4
5import { effortFromSettings, pickEffort } from './effort-level'
6import type { EffortTracker } from './effort-level'
7import { describeStatus, formatHead } from './format-status'
8import type { StatusLevel } from './format-status'
9import type { ContextFigures, StatusSnapshot } from '../types'
10
11// /model and /effort have no mod event, so a cheap poll catches a switch between turns.
12const POLL_MS = 5000
13
14const LEVEL_COLOR: Record<StatusLevel, string> = {
15  unknown: 'subtle',
16  ok: 'success',
17  warn: 'warning',
18  critical: 'error',
19}
20
21const snapshot = atom({ plugin: 'context-status', key: 'snapshot' } as const, null)
22
23function isSame(a: StatusSnapshot | null, b: StatusSnapshot): boolean {
24  return (
25    a !== null &&
26    a.model === b.model &&
27    a.effort === b.effort &&
28    a.context.tokens === b.context.tokens &&
29    a.context.window === b.context.window &&
30    a.context.percent === b.context.percent
31  )
32}
33
34// Reads model, effort and usage (all local, free calls); the band redraws only when a figure changed.
35async function refresh($: EngineInterface, tracker: EffortTracker, measured?: ContextFigures): Promise<void> {
36  try {
37    const model = await $.session.model()
38    const settings = await $.settings.read()
39    const effort = pickEffort(tracker, effortFromSettings(settings, model), model)
40    const { tokens, window, percent } = measured ?? (await $.session.usage()).context
41    const next: StatusSnapshot = { model, effort, context: { tokens, window, percent } }
42    await update($, snapshot, previous => (isSame(previous, next) ? previous : next))
43  } catch {
44    // Keep the last known figures; a failed read must never break the session.
45  }
46}
47
48export const register: Register = on => {
49  let poll: Timer | undefined
50  const tracker: EffortTracker = {}
51
52  on('session.start', async ($, e, next) => {
53    $.ui.status(undefined) // clears the line version 0.1.0 of this mod pinned
54    await refresh($, tracker)
55    poll?.cancel()
56    poll = $.clock.every(POLL_MS, () => void refresh($, tracker))
57
58    return next(e)
59  })
60
61  // The effort the main loop actually sends, after any downgrade for the model.
62  on('turn.step', async function* ($, e, next) {
63    if (e.agentId === undefined && e.effort !== tracker.fromTurn) {
64      tracker.fromTurn = e.effort
65      await refresh($, tracker)
66    }
67
68    return yield* next(e)
69  })
70
71  on('session.measure', async ($, e, next) => {
72    await refresh($, tracker, e.context)
73
74    return next(e)
75  })
76
77  on('turn.complete', async ($, e, next) => {
78    const result = await next(e)
79    await refresh($, tracker)
80
81    return result
82  })
83
84  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
85    const current = await read($, snapshot)
86    if (e.props.hasSurvey || current === null) {
87      return next(e)
88    }
89    const { Box, Text } = $.ui.resolve(e)
90    const parts = describeStatus(current.model, current.context, current.effort)
91    const color = LEVEL_COLOR[parts.level]
92
93    return (
94      <Box key="context-status">
95        <Text dimColor wrap="truncate">
96          {`${formatHead(parts)}ctx `}
97        </Text>
98        <Text key="bar" color={color} wrap="truncate">
99          {parts.bar === '' ? '' : `${parts.bar} `}
100        </Text>
101        <Text key="usage" color={parts.level === 'ok' ? undefined : color} wrap="truncate">
102          {parts.usage}
103        </Text>
104      </Box>
105    )
106  })
107}
108
hooks/effort-level.ts 47 lines
1// Works out which reasoning effort to show: the last main-loop request's, or the settings' value.
2
3export type EffortValue = string | number
4
5// What the mod has seen of the effort so far.
6export type EffortTracker = {
7  // The effort the last main-loop model request was sent with.
8  fromTurn?: EffortValue
9  // The settings' value at the previous read, to tell when it changed.
10  lastSettings?: EffortValue
11  // The model the last read was for, to tell when it changed.
12  lastModel?: string
13}
14
15function asEffort(value: unknown): EffortValue | undefined {
16  return typeof value === 'string' || typeof value === 'number' ? value : undefined
17}
18
19// modelSettings["<model id>"].effortLevel wins over the top-level effortLevel.
20export function effortFromSettings(settings: Readonly<Record<string, unknown>>, model: string): EffortValue | undefined {
21  const perModel = settings['modelSettings']
22  if (typeof perModel === 'object' && perModel !== null) {
23    const entry = (perModel as Record<string, unknown>)[model]
24    if (typeof entry === 'object' && entry !== null) {
25      const level = asEffort((entry as Record<string, unknown>)['effortLevel'])
26      if (level !== undefined) {
27        return level
28      }
29    }
30  }
31
32  return asEffort(settings['effortLevel'])
33}
34
35// A turn's value is the truth until the settings or the model change; then the settings speak until the next turn.
36export function pickEffort(tracker: EffortTracker, settingsEffort: EffortValue | undefined, model: string): EffortValue | undefined {
37  if (settingsEffort !== tracker.lastSettings || model !== tracker.lastModel) {
38    if (tracker.lastModel !== undefined) {
39      tracker.fromTurn = undefined
40    }
41    tracker.lastSettings = settingsEffort
42    tracker.lastModel = model
43  }
44
45  return tracker.fromTurn ?? settingsEffort
46}
47
hooks/format-status.ts 95 lines
1// Pure formatting of the indicator: model name, effort, context bar, token counts, usage level.
2import type { ContextFigures } from '../types'
3
4export type StatusLevel = 'unknown' | 'ok' | 'warn' | 'critical'
5
6export type StatusParts = {
7  model: string
8  effort: string
9  bar: string
10  usage: string
11  level: StatusLevel
12}
13
14const BAR_CELLS = 10
15const WARN_PERCENT = 80
16const CRITICAL_PERCENT = 95
17
18// "claude-opus-5-5" -> "Opus 5.5"; "claude-sonnet-5-5[1m]" -> "Sonnet 5.5 [1m]"; anything else unchanged.
19export function formatModelName(model: string): string {
20  const match = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[[^\]]+\])?$/i.exec(model.trim())
21  if (match === null) {
22    return model.trim()
23  }
24  const [, family = '', major, minor, suffix] = match
25  const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
26  const version = minor === undefined ? major : `${major}.${minor}`
27
28  return suffix === undefined ? `${name} ${version}` : `${name} ${version} ${suffix}`
29}
30
31// 950 -> "950", 1500 -> "1.5k", 84000 -> "84k", 1000000 -> "1M".
32export function formatTokens(count: number): string {
33  if (count >= 1_000_000) {
34    return `${trimZero(count / 1_000_000)}M`
35  }
36  if (count >= 1_000) {
37    return `${trimZero(count / 1_000)}k`
38  }
39
40  return String(Math.round(count))
41}
42
43function trimZero(value: number): string {
44  return value >= 100 ? String(Math.round(value)) : String(Number(value.toFixed(1)))
45}
46
47export function formatBar(percent: number): string {
48  const clamped = Math.min(100, Math.max(0, percent))
49  const filled = Math.round(clamped / (100 / BAR_CELLS))
50
51  return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
52}
53
54export function usageLevel(percent: number | undefined): StatusLevel {
55  if (percent === undefined) {
56    return 'unknown'
57  }
58  if (percent >= CRITICAL_PERCENT) {
59    return 'critical'
60  }
61
62  return percent >= WARN_PERCENT ? 'warn' : 'ok'
63}
64
65export function describeStatus(model: string, context: ContextFigures, effort?: string | number): StatusParts {
66  const head = { model: formatModelName(model), effort: effort === undefined ? '' : String(effort) }
67  const window = formatTokens(context.window)
68  const level = usageLevel(context.percent)
69  if (context.percent === undefined) {
70    return { ...head, bar: '', usage: `— · ${window}`, level }
71  }
72  const used = context.tokens === undefined ? '?' : formatTokens(context.tokens)
73  const note = level === 'critical' ? ' · compact soon' : ''
74
75  return {
76    ...head,
77    bar: formatBar(context.percent),
78    usage: `${context.percent}% · ${used}/${window}${note}`,
79    level,
80  }
81}
82
83// The indicator as one plain line, e.g. "Opus 5.5 · high · ctx ████░░░░░░ 42% · 84k/200k".
84export function formatStatus(model: string, context: ContextFigures, effort?: string | number): string {
85  const parts = describeStatus(model, context, effort)
86  const bar = parts.bar === '' ? '' : `${parts.bar} `
87
88  return `${formatHead(parts)}ctx ${bar}${parts.usage}`
89}
90
91// "Opus 5.5 · high · ", or "Opus 5.5 · " for a model without effort.
92export function formatHead(parts: StatusParts): string {
93  return parts.effort === '' ? `${parts.model} · ` : `${parts.model} · ${parts.effort} · `
94}
95
types/index.d.ts 11 lines
1// State contract: the last model, effort and context figures the band draws.
2export type ContextFigures = { tokens?: number; window: number; percent?: number }
3
4export type StatusSnapshot = { model: string; effort?: string | number; context: ContextFigures }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'context-status': { snapshot: StatusSnapshot | null }
9  }
10}
11