SLOPSHOPPER

context-bar

Context window as a stacked bar above the prompt, one color per category, toggled with /context-bar

newbandcommand
v0.1.0no licenseupdated 2026-10-05letnonesurvive/claude-code-mods/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
› 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 › /context-bar ⎿ context-bar: Context bar shown. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

Personal Claude Code mods and the setup around them.

Install

git clone https://github.com/letnonesurvive/claude-code-mods.git ~/.claude/mods && ~/.claude/mods/install.sh

Open a new terminal afterwards. The script registers every mod of this repository through CLAUDE_CODE_PLUGIN_DIRS in ~/.zshrc and is safe to run again. Update with git -C ~/.claude/mods pull.

Mods are an early-access Claude Code feature: the API may change between releases. Written against Claude Code 2.1.286.

Mods

context-bar

A band above the prompt, toggled with /context-bar (the choice is remembered across sessions):

  • the context window as a stacked bar, one color per category as /context draws them, with tokens and share per category;
  • the subscription limits (session, week) with the time until each resets.

The breakdown is estimated locally; it sends no token-count requests, so the figures may differ slightly from /context.

iTerm2: hotkeys on a non-Latin layout

On a Russian layout ctrl+ч (the ctrl+x key) does not trigger Claude Code hotkeys in iTerm2, because iTerm2 reports the Cyrillic letter. To fix it, quit iTerm2 and run from Terminal.app:

~/.claude/mods/install.sh --iterm

For every profile this turns off "Apps can change how keys are reported" and maps shift+enter to send LF, which keeps it inserting a newline. The previous preferences are saved to ~/iterm2-prefs-backup-*.plist. Add --dry-run to see what would change.

Source 2 files
hooks/register.tsx 227 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Row, Snapshot } from '../types'
5
6const COMMAND = 'context-bar'
7const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, false)
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9
10const tokens = (n: number): string => {
11  if (n >= 1_000_000) {
12    return `${+(n / 1_000_000).toFixed(1)}M`
13  }
14
15  if (n >= 1000) {
16    return `${+(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
17  }
18
19  return String(n)
20}
21
22const share = (n: number, max: number): string => {
23  const percent = max > 0 ? (n / max) * 100 : 0
24
25  return `${+percent.toFixed(percent < 1 ? 1 : 0)}%`
26}
27
28// Locally estimated breakdown: no token-count requests are sent.
29const measure = async ($: EngineInterface): Promise<void> => {
30  const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
31  const breakdown = context.breakdown
32
33  if (!breakdown) {
34    return
35  }
36
37  const rows: Row[] = []
38
39  for (const one of breakdown.categories) {
40    if (one.kind !== 'deferred' && one.tokens > 0) {
41      rows.push({
42        name: one.name.toLowerCase(),
43        tokens: one.tokens,
44        color: one.color,
45        kind: one.kind,
46      })
47    }
48  }
49
50  const next: Snapshot = {
51    rows,
52    total: breakdown.totalTokens,
53    max: breakdown.rawMaxTokens,
54    percent: breakdown.percentage,
55    compactsAt: breakdown.isAutoCompactEnabled
56      ? (breakdown.autoCompactThreshold ?? null)
57      : null,
58    limits: rateLimits.map(
59      (one): Limit => ({
60        kind: one.kind,
61        percent: one.percentUsed,
62        resetsAt: one.resetsAt ?? null,
63      }),
64    ),
65  }
66  await update($, snapshot, () => next)
67}
68
69// Splits `width` cells among the rows by tokens; every row keeps one cell.
70const cells = (rows: Row[], width: number): number[] => {
71  const sum = rows.reduce((all, row) => all + row.tokens, 0) || 1
72  const out = rows.map(row => Math.max(1, Math.round((row.tokens / sum) * width)))
73  let spare = width - out.reduce((all, n) => all + n, 0)
74
75  while (spare !== 0) {
76    const widest = out.indexOf(Math.max(...out))
77
78    const cellsNow = out[widest] ?? 0
79
80    if (spare < 0 && cellsNow <= 1) {
81      break
82    }
83
84    out[widest] = cellsNow + Math.sign(spare)
85    spare -= Math.sign(spare)
86  }
87
88  return out
89}
90
91const LIMIT_NAMES: Record<string, string> = {
92  five_hour: 'session',
93  seven_day: 'week',
94  spend_limit: 'spend',
95}
96
97const until = (resetsAt: string | null, now: number): string => {
98  const left = resetsAt === null ? NaN : Date.parse(resetsAt) - now
99
100  if (!(left > 0)) {
101    return ''
102  }
103
104  const minutes = Math.ceil(left / 60_000)
105  const days = Math.floor(minutes / 1440)
106  const hours = Math.floor((minutes % 1440) / 60)
107
108  if (days > 0) {
109    return ` · resets in ${days}d ${hours}h`
110  }
111
112  return ` · resets in ${hours > 0 ? `${hours}h ` : ''}${minutes % 60}m`
113}
114
115const tone = (percent: number): string =>
116  percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
117
118const GLYPH = { used: '█', free: '░', buffer: '▒' } as const
119
120export const register: Register = on => {
121  on('session.start', async ($, e, next) => {
122    await $.command.register({
123      name: COMMAND,
124      description: 'Toggle the context window bar above the prompt',
125    })
126
127    // The choice outlives the session in $.store; $.state is per session.
128    if ((await read($, isOn)) || (await $.store.get('isOn')) === true) {
129      await update($, isOn, () => true)
130      void measure($)
131    }
132
133    return next(e)
134  })
135
136  on('command.run', { command: COMMAND }, async $ => {
137    const shown = !(await read($, isOn))
138
139    if (shown) {
140      await measure($)
141    }
142
143    await update($, isOn, () => shown)
144    await $.store.set('isOn', shown)
145
146    return { text: shown ? 'Context bar shown.' : 'Context bar hidden.' }
147  })
148
149  on('session.measure', async ($, e, next) => {
150    if (await read($, isOn)) {
151      void measure($)
152    }
153
154    return next(e)
155  })
156
157  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158    const shown = await read($, isOn)
159    const snap = await read($, snapshot)
160
161    if (e.props.hasSurvey || !shown || snap === null) {
162      return next(e)
163    }
164
165    const { Box, Text } = $.ui.resolve(e)
166    // Border and padding take four cells of the band.
167    const width = Math.max(10, e.props.bodyColumns - 4)
168    const widths = cells(snap.rows, width)
169    const level = tone(snap.percent)
170    const now = await $.clock.now()
171    const meter = Math.max(8, Math.min(40, width - 40))
172
173    return (
174      <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
175        <Box justifyContent="space-between">
176          <Text bold>
177            <Text color="claude">◆</Text> context
178          </Text>
179          <Text>
180            <Text bold>{tokens(snap.total)}</Text>
181            <Text dimColor>
182              {' '}
183              of {tokens(snap.max)}
184              {snap.compactsAt === null ? '' : ` · compacts at ${tokens(snap.compactsAt)}`}{' '}
185            </Text>
186            <Text color={level} inverse bold>
187              {' '}
188              {snap.percent}%{' '}
189            </Text>
190          </Text>
191        </Box>
192        <Text wrap="truncate">
193          {snap.rows.map((row, i) => (
194            <Text color={row.color} dimColor={row.kind === 'free'}>
195              {GLYPH[row.kind].repeat(widths[i] ?? 0)}
196            </Text>
197          ))}
198        </Text>
199        <Box flexWrap="wrap" columnGap={3}>
200          {snap.rows.map(row => (
201            <Text>
202              <Text color={row.color} dimColor={row.kind === 'free'}>
203                {GLYPH[row.kind]}
204              </Text>{' '}
205              {row.name} <Text bold>{tokens(row.tokens)}</Text>
206              {row.kind === 'free' ? '' : <Text dimColor> {share(row.tokens, snap.max)}</Text>}
207            </Text>
208          ))}
209        </Box>
210        {(snap.limits ?? []).map(limit => {
211          const filled = Math.min(meter, Math.round((limit.percent / 100) * meter))
212
213          return (
214            <Text wrap="truncate">
215              {(LIMIT_NAMES[limit.kind] ?? limit.kind).padEnd(8)}
216              <Text color={tone(limit.percent)}>{'━'.repeat(filled)}</Text>
217              <Text dimColor>{'─'.repeat(meter - filled)}</Text>{' '}
218              <Text bold>{limit.percent}%</Text>
219              <Text dimColor> used{until(limit.resetsAt, now)}</Text>
220            </Text>
221          )
222        })}
223      </Box>
224    )
225  })
226}
227
types/index.d.ts 24 lines
1export type Row = {
2  name: string
3  tokens: number
4  color: string
5  kind: 'used' | 'free' | 'buffer'
6}
7
8export type Limit = { kind: string; percent: number; resetsAt: string | null }
9
10export type Snapshot = {
11  rows: Row[]
12  total: number
13  max: number
14  percent: number
15  compactsAt: number | null
16  limits: Limit[]
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'context-bar': { isOn: boolean; snapshot: Snapshot | null }
22  }
23}
24