SLOPSHOPPER

jr-context

The context window in one quiet row above the prompt: a mini bar coloured by category, the fill and the window, turning into an alert when compaction is near…

newpanebandguardcommandtimer
★ 2v0.5.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-context
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-context
│ ┃ jr-context ✕ › fix the failing auth test and add an audit log call │ ┃ Reading the context window… │ ⏺ 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 │ │ › /jrcontext │ ⎿ jr-context: jr context · hidden (/jrcontext to bring it back) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-context
Reading the context window…
README

jr-context

The context window in one quiet row above the Claude Code prompt, aligned to the right so it stays out of the way.

                                              ctx ▕█████─────────────▏ 26% · 256k/1.0M
  • A mini bar coloured by category (system prompt, tools, memory, skills, messages...), the fill in a colour from green to red, the tokens used and the window.
  • Once the window reaches the warning threshold of where compaction starts (80 % by default), the row moves to the left as an alert:
▲ context 85% · compacts in 117k · /jrcontext details
  • /jrcontext details opens the full view in a pane: header, the stacked bar across the width, and the legend with each category's tokens and share.
◆ context                          272k of 1.0M · compacts at 967k  27%
███████████████████────────────────────────────────────────────────▓▓
■ system prompt 2.2k 0.2%   ■ system tools 21k 2.1%   ■ messages 233k 23%
─ free space 695k   █ autocompact buffer 33k
  • The figures are the engine's own, the same breakdown as /context.

Commands

Command
/jrcontextHide the band above the prompt, or bring it back
/jrcontext hide · showHide it or show it explicitly (off and on work too)
/jrcontext detailsOpen the full bar, legend included, in a pane

Install

/plugin install jr-context --marketplace jrlprost/claude-code-mods

Settings (/config)

OptionDefault
Warn at (%)80Share of the compaction point from which the row becomes an alert

Access

Reads the session's context usage. Writes nothing, runs nothing, sends nothing over the network.

Source 5 files
hooks/register.tsx 207 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrContextFill } from '../types'
5import { compactsAtOf, fillOf, isNearCompaction, miniBar, segmentColor, share, stackedBar } from './fill'
6import { gradientText } from './jr/draw'
7import { GRADIENTS, colorAt, formatTokens } from './jr/gradient'
8
9const fill = atom({ plugin: 'jr-context', key: 'fill' } as const, null)
10const bandHidden = atom({ plugin: 'jr-context', key: 'isBandHidden' } as const, false)
11const band = { isLoaded: false }
12
13/** `hide`/`off`, `show`/`on`, or a bare command, which toggles; anything else is not about the band. */
14const bandVerb = (args: string) => {
15  const arg = args.trim().toLowerCase()
16  if (arg === 'hide' || arg === 'off') return 'hide' as const
17  if (arg === 'show' || arg === 'on') return 'show' as const
18  return arg === '' ? ('toggle' as const) : null
19}
20
21/** Hidden or shown is one choice for every session, kept across restarts. */
22async function isBandHidden($: EngineInterface) {
23  if (!band.isLoaded) {
24    band.isLoaded = true
25    if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
26  }
27  return read($, bandHidden)
28}
29
30async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
31  const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
32  await $.store.set('isBandHidden', hide)
33  await update($, bandHidden, () => hide)
34  return { text: hide ? 'jr context · hidden (/jrcontext to bring it back)' : 'jr context · shown' }
35}
36const PANE = 'jr-context'
37const REFRESH_EVERY_MS = 4000
38const last = { refreshedAt: 0, isScheduled: false }
39
40async function refresh($: EngineInterface, force = false) {
41  const now = await $.clock.now()
42  if (!force && now - last.refreshedAt < REFRESH_EVERY_MS) return
43  last.refreshedAt = now
44  const usage = await $.session.usage({ breakdown: 'summary' })
45  await update($, fill, () => fillOf(usage.context.breakdown))
46}
47
48type Elements = ReturnType<EngineInterface['ui']['resolve']>
49
50const AMBER = '#ff8c42'
51const RED = '#ef4565'
52const MINI_BAR_CELLS = 18
53
54/** The full view of the pane: the header, the stacked bar and the legend. */
55function drawContext(Box: Elements['Box'], Text: Elements['Text'], now: JrContextFill, width: number, warnAt: number) {
56  const heat = colorAt(GRADIENTS.heat, now.percent / 100)
57  const compactsAt = compactsAtOf(now)
58  const isWarning = isNearCompaction(now, warnAt)
59
60  const header = (
61    <Box justifyContent="space-between" width={width}>
62      <Box>
63        <Text color="#ff8c42">{'◆ '}</Text>
64        {gradientText(Text, 'context', GRADIENTS.brand)}
65        {isWarning && <Text color={heat}>{'  compaction is near'}</Text>}
66      </Box>
67      <Box>
68        <Text dimColor>{`${formatTokens(now.total)} of ${formatTokens(now.max)} · compacts at ${formatTokens(compactsAt)} `}</Text>
69        <Text backgroundColor={heat} color="black" bold>{` ${now.percent}% `}</Text>
70      </Box>
71    </Box>
72  )
73
74  const bar = (
75    <Text>
76      {stackedBar(now, width).map(cell =>
77        cell.kind === 'free' ? <Text color={cell.color} dimColor>{cell.char}</Text> : <Text color={cell.color}>{cell.char}</Text>,
78      )}
79    </Text>
80  )
81
82  const legend = (
83    <Box flexWrap="wrap" columnGap={3} width={width}>
84      {now.used.map((segment, i) => (
85        <Text>
86          <Text color={segmentColor(i, now.used.length)}>{'■ '}</Text>
87          {`${segment.name} `}
88          <Text bold>{formatTokens(segment.tokens)}</Text>
89          <Text dimColor>{` ${share(segment.tokens, now.max)}`}</Text>
90        </Text>
91      ))}
92      <Text dimColor>{`─ free space ${formatTokens(Math.max(0, compactsAt - now.total))}`}</Text>
93      <Text>
94        <Text color="#3b3d4f">{'█ '}</Text>
95        <Text dimColor>{`autocompact buffer ${formatTokens(now.buffer)}`}</Text>
96      </Text>
97    </Box>
98  )
99
100  return (
101    <Box flexDirection="column">
102      {header}
103      {bar}
104      {legend}
105    </Box>
106  )
107}
108
109/**
110 * The band: one row. Quiet and right-aligned while there is room; once compaction is near,
111 * an alert on the left with what is left before it.
112 */
113function drawRow(Box: Elements['Box'], Text: Elements['Text'], now: JrContextFill, width: number, warnAt: number) {
114  if (isNearCompaction(now, warnAt)) {
115    const compactsAt = compactsAtOf(now)
116    const color = isNearCompaction(now, 95) ? RED : AMBER
117    return (
118      <Box key="jr-context-row" width={width}>
119        <Text wrap="truncate-end">
120          <Text color={color}>{`▲ context ${now.percent}%`}</Text>
121          <Text dimColor>{` · compacts in ${formatTokens(Math.max(0, compactsAt - now.total))} · /jrcontext details`}</Text>
122        </Text>
123      </Box>
124    )
125  }
126  return (
127    <Box key="jr-context-row" width={width} justifyContent="flex-end">
128      <Text wrap="truncate-end">
129        <Text dimColor>{'ctx ▕'}</Text>
130        {miniBar(now, MINI_BAR_CELLS).map(cell =>
131          cell.kind === 'free' ? <Text color={cell.color} dimColor>{cell.char}</Text> : <Text color={cell.color}>{cell.char}</Text>,
132        )}
133        <Text dimColor>{'▏ '}</Text>
134        <Text color={colorAt(GRADIENTS.heat, now.percent / 100)}>{`${now.percent}%`}</Text>
135        <Text dimColor>{` · ${formatTokens(now.total)}/${formatTokens(now.max)}`}</Text>
136      </Text>
137    </Box>
138  )
139}
140
141export const register: Register = (on, options) => {
142  const warnAt = Number(options.warnAt ?? 80)
143
144  on('session.start', async ($, e, next) => {
145    const started = await next(e)
146    await $.command.register({ name: 'jrcontext', description: 'Show or hide the context band; /jrcontext details opens the full bar' })
147    await refresh($, true)
148    return started
149  })
150
151  on('command.run', { command: 'jrcontext' }, async ($, e) => {
152    const verb = bandVerb(e.args)
153    if (verb) return setBand($, verb)
154    await refresh($, true)
155    await $.ui.open({ id: PANE, title: 'jr context', rows: 8 })
156    return { text: 'jr context opened.' }
157  })
158
159  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
160    const { Box, Text } = $.ui.resolve(e)
161    const now = await read($, fill)
162    if (!now) return <Text dimColor>Reading the context window…</Text>
163    return drawContext(Box, Text, now, Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80), warnAt)
164  })
165
166  // The window grows with every tool result; refreshed at most every few seconds.
167  on('tool.call', async ($, e, next) => {
168    const ran = await next(e)
169    await refresh($)
170    return ran
171  })
172
173  on('turn.complete', async ($, e, next) => {
174    const done = await next(e)
175    await refresh($, true)
176    return done
177  })
178
179  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180    const below = await next(e)
181    // Never write state while drawing: the saved choice is loaded just after.
182    if (!band.isLoaded) {
183      $.clock.after(0, () => {
184        void isBandHidden($)
185      })
186    }
187    if (await read($, bandHidden)) return below
188    const now = await read($, fill)
189    // Installed or reloaded mid-session, the mod saw no session.start: read the figures once now.
190    if (!now && !last.isScheduled) {
191      last.isScheduled = true
192      $.clock.after(0, () => {
193        void refresh($, true)
194      })
195    }
196    if (!now || e.props.hasSurvey) return below
197
198    const { Box, Text } = $.ui.resolve(e)
199    return (
200      <Box flexDirection="column">
201        {drawRow(Box, Text, now, Math.max(20, e.props.bodyColumns), warnAt)}
202        {below}
203      </Box>
204    )
205  })
206}
207
hooks/fill.ts 79 lines
1import type { JrContextFill } from '../types'
2
3// One hue per category, in the order the engine lists them; messages usually come last.
4export const SEGMENT_COLORS = ['#7f5af0', '#3da9fc', '#c084fc', '#2cb67d', '#f4d35e', '#ff5ca8', '#4fd6be', '#ff8c42']
5
6export const segmentColor = (index: number, count: number) =>
7  // The last used row (the conversation itself) always takes the warm accent.
8  index === count - 1 ? '#ff8c42' : (SEGMENT_COLORS[index % (SEGMENT_COLORS.length - 1)] ?? '#7f5af0')
9
10type Breakdown = {
11  categories: readonly { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }[]
12  totalTokens: number
13  rawMaxTokens: number
14  percentage: number
15}
16
17/** The engine's /context breakdown, reduced to what the bar draws. */
18export const fillOf = (breakdown: Breakdown | undefined): JrContextFill | null => {
19  if (!breakdown || breakdown.rawMaxTokens <= 0) return null
20  return {
21    used: breakdown.categories.filter(c => c.kind === 'used' && c.tokens > 0).map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens })),
22    total: breakdown.totalTokens,
23    max: breakdown.rawMaxTokens,
24    buffer: breakdown.categories.find(c => c.kind === 'buffer')?.tokens ?? 0,
25    percent: breakdown.percentage,
26  }
27}
28
29export type BarCell = { char: string; color: string; kind: 'used' | 'free' | 'buffer' }
30
31/**
32 * The stacked bar, `width` cells: each used category gets its share (at least one
33 * cell when it holds anything), then the free space as a thin line, then the
34 * compaction buffer as a dim block at the end.
35 */
36export const stackedBar = (fill: JrContextFill, width: number): BarCell[] => {
37  const perCell = fill.max / width
38  const bufferCells = fill.buffer > 0 ? Math.max(1, Math.round(fill.buffer / perCell)) : 0
39  const cells: BarCell[] = []
40  fill.used.forEach((segment, i) => {
41    const n = Math.max(1, Math.round(segment.tokens / perCell))
42    const color = segmentColor(i, fill.used.length)
43    for (let k = 0; k < n && cells.length < width - bufferCells; k++) cells.push({ char: '█', color, kind: 'used' })
44  })
45  while (cells.length < width - bufferCells) cells.push({ char: '─', color: '#5c5f77', kind: 'free' })
46  while (cells.length < width) cells.push({ char: '█', color: '#3b3d4f', kind: 'buffer' })
47  return cells
48}
49
50export const share = (tokens: number, max: number) => {
51  const p = (tokens / max) * 100
52  if (p < 0.1) return '<0.1%'
53  return p < 10 ? `${p.toFixed(1)}%` : `${Math.round(p)}%`
54}
55
56/** Where compaction starts: the window less the buffer the engine keeps free. */
57export const compactsAtOf = (fill: JrContextFill) => fill.max - fill.buffer
58
59/** True once the fill (the engine's percentage) reaches `warnAt` percent of where compaction starts. */
60export const isNearCompaction = (fill: JrContextFill, warnAt: number) => (fill.percent / 100) * fill.max >= (compactsAtOf(fill) * warnAt) / 100
61
62/**
63 * The mini bar of the one-row band, `width` cells: the filled share coloured by category
64 * in proportion (a tiny category may get no cell), then the free space as a thin line.
65 */
66export const miniBar = (fill: JrContextFill, width: number): BarCell[] => {
67  const usedCells = Math.min(width, Math.max(fill.total > 0 ? 1 : 0, Math.round((fill.total / fill.max) * width)))
68  const cells: BarCell[] = []
69  let sum = 0
70  fill.used.forEach((segment, i) => {
71    sum += segment.tokens
72    const upTo = Math.min(usedCells, Math.round((sum / Math.max(1, fill.total)) * usedCells))
73    while (cells.length < upTo) cells.push({ char: '█', color: segmentColor(i, fill.used.length), kind: 'used' })
74  })
75  while (cells.length < usedCells) cells.push({ char: '█', color: '#ff8c42', kind: 'used' })
76  while (cells.length < width) cells.push({ char: '─', color: '#5c5f77', kind: 'free' })
77  return cells
78}
79
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
types/index.d.ts 20 lines
1export type JrContextSegment = { name: string; tokens: number }
2
3export type JrContextFill = {
4  /** What occupies the window, in the order the engine lists it. */
5  used: JrContextSegment[]
6  /** Tokens in use. */
7  total: number
8  /** The window measured against. */
9  max: number
10  /** The compaction reserve at the end of the window. */
11  buffer: number
12  percent: number
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'jr-context': { fill: JrContextFill | null; isBandHidden: boolean }
18  }
19}
20