SLOPSHOPPER

context-bar

A band above the prompt with model, effort, folder, git branch and a context usage bar

newbandspinnertoastprocesstimer
v0.1.0MITupdated 2026-10-04vrnrn/agent-harness/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 ▶ Opus 5.5 ▴ ▶ work/app ▶ feat/auth-refresh 49% ━━━━━━━━━━━━━━━━━━━●─────────────────── 97k/200k ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ›

Draws

Band
▶ Opus 5.5 ▴ ▶ work/app ▶ feat/auth-refresh 49% ━━━━━━━━━━━━━━━━━━━●─────────────────── 97k/200k ⟨Claude Code's own drawing⟩
README

Context bar

Keep model and effort controls, folder, Git branch, permission mode, and context usage just above the prompt.

The context bar with its effort menu open in Claude Code

Install

claude plugin marketplace add vrnrn/agent-harness
claude plugin install context-bar@vrnrn

Requires the Claude Code mods API. Available model, effort, and permission choices depend on the host. The screenshot also shows the separate image-preview mod.

See the mod guide for compatibility, local loading, validation, and tests.

Source 2 files
hooks/register.tsx 291 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Info } from '../types'
5
6const empty: Info = {
7  model: '',
8  effort: '',
9  dir: '',
10  branch: '',
11  mode: '',
12  tokens: 0,
13  window: 0,
14  percent: 0,
15  compactAt: 0,
16}
17
18const info = atom({ plugin: 'context-bar', key: 'info' } as const, empty)
19// Which menu is open above the bar, if any.
20const menu = atom({ plugin: 'context-bar', key: 'menu' } as const, null)
21
22function short(n: number): string {
23  if (n >= 1_000_000) {
24    return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
25  }
26
27  return n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
28}
29
30function modelName(id: string): string {
31  const name = (family: string, major: string, minor?: string) =>
32    `${family[0].toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}`
33  const current = id.match(/claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2})(?!\d))?/)
34
35  if (current) {
36    return name(current[1], current[2], current[3])
37  }
38
39  const legacy = id.match(/claude-(\d+)(?:-(\d{1,2})(?!\d))?-(opus|sonnet|haiku)/)
40
41  return legacy ? name(legacy[3], legacy[1], legacy[2]) : id
42}
43
44function tail(path: string): string {
45  return path.split('/').filter(Boolean).slice(-2).join('/') || path
46}
47
48// Yellow from 50 percent, red at the compact threshold (85 percent when none is set).
49function levelColor(percent: number, compactAt: number): string {
50  return percent >= (compactAt > 0 ? compactAt : 85) ? 'red' : percent >= 50 ? 'yellow' : 'white'
51}
52
53// The bar as runs of one look each: the fill, the head, the rest, and the compact line.
54type Run = { kind: 'fill' | 'head' | 'rest' | 'line'; text: string }
55
56function runs(length: number, head: number, line: number | null): Run[] {
57  const out: Run[] = []
58
59  for (let n = 0; n < length; n++) {
60    const kind = n === line ? 'line' : n < head ? 'fill' : n === head ? 'head' : 'rest'
61    const text = kind === 'line' ? '┃' : kind === 'fill' ? '━' : kind === 'head' ? '●' : '─'
62    const last = out[out.length - 1]
63
64    if (last && last.kind === kind && kind !== 'line') {
65      last.text += text
66    } else {
67      out.push({ kind, text })
68    }
69  }
70
71  return out
72}
73
74const modeLabels: Record<string, string> = {
75  acceptEdits: 'accept edits',
76  plan: 'plan',
77  bypassPermissions: 'bypass',
78  dontAsk: 'dont ask',
79  auto: 'auto',
80}
81
82type Live = Pick<Info, 'model' | 'effort' | 'mode'>
83
84const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
85
86// The `/model` family aliases. The engine maps each to the newest model of its family,
87// so the menu follows new releases when Claude Code updates. Pinned model IDs would not.
88const MODELS = [
89  { label: 'Fable', alias: 'fable' },
90  { label: 'Opus', alias: 'opus' },
91  { label: 'Sonnet', alias: 'sonnet' },
92  { label: 'Haiku', alias: 'haiku' },
93]
94
95// The level the person cycled to, kept until the settings change under it.
96let pressed: { level: string; baseline: string } | null = null
97
98function effortOf(model: string, settings: Record<string, unknown>): string {
99  const perModel = settings.modelSettings as Record<string, { effortLevel?: string }> | undefined
100  const override = Object.entries(perModel ?? {}).find(([id]) => model.includes(id))?.[1]
101
102  return String(override?.effortLevel ?? settings.effortLevel ?? '')
103}
104
105async function live($: EngineInterface): Promise<Live> {
106  const [model, rows, settings] = await Promise.all([
107    $.session.model(),
108    $.config.list(),
109    $.settings.read(),
110  ])
111  const mode = rows.find(row => row.key === 'permissionMode')?.value
112  const fromSettings = effortOf(model, settings)
113
114  if (pressed && pressed.baseline !== fromSettings) {
115    pressed = null
116  }
117
118  return {
119    model: modelName(model),
120    effort: pressed?.level ?? fromSettings,
121    mode: modeLabels[String(mode)] ?? '',
122  }
123}
124
125// Runs `/effort` with the chosen level, shows it at once, and closes the menu.
126async function setEffort($: EngineInterface, level: string) {
127  const [model, settings] = await Promise.all([$.session.model(), $.settings.read()])
128
129  await update($, menu, () => null)
130  await $.command.run({ command: 'effort', args: level })
131  pressed = { level, baseline: effortOf(model, settings) }
132  await update($, info, i => ({ ...i, effort: level }))
133  $.ui.toast(`Effort: ${level}`)
134}
135
136// Runs `/model` with the chosen alias, then checks that the session took it.
137async function setModel($: EngineInterface, choice: { label: string; alias: string }) {
138  const before = await $.session.model()
139
140  await update($, menu, () => null)
141  await $.command.run({ command: 'model', args: choice.alias })
142
143  const after = await $.session.model()
144
145  pressed = null
146  await sync($)
147  $.ui.toast(after === before ? `Could not switch to ${choice.label}` : `Model: ${modelName(after)}`)
148}
149
150// The percent of the window where auto-compact starts, from the settings that name it.
151async function compactPercent($: EngineInterface, window: number): Promise<number> {
152  const [percent, tokens] = await Promise.all([
153    $.env.get('CLAUDE_AUTOCOMPACT_PCT_OVERRIDE'),
154    $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW'),
155  ])
156  const fromPercent = Number.parseFloat(percent ?? '')
157  const fromTokens = Number.parseFloat(tokens ?? '')
158
159  if (fromPercent > 0 && fromPercent <= 100) {
160    return Math.round(fromPercent)
161  }
162
163  return fromTokens > 0 && window > 0 ? Math.min(100, Math.round((fromTokens / window) * 100)) : 0
164}
165
166async function refresh($: EngineInterface) {
167  const [usage, cwd, now] = await Promise.all([$.session.usage(), $.session.cwd(), live($)])
168  const git = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
169  const next: Info = {
170    ...now,
171    dir: tail(cwd),
172    branch: git.exitCode === 0 ? git.stdout.trim() : '',
173    tokens: usage.context.tokens ?? 0,
174    window: usage.context.window,
175    percent: usage.context.percent ?? 0,
176    compactAt: await compactPercent($, usage.context.window),
177  }
178  await update($, info, () => next)
179}
180
181async function sync($: EngineInterface) {
182  const [now, current] = await Promise.all([live($), read($, info)])
183
184  if (now.model !== current.model || now.effort !== current.effort || now.mode !== current.mode) {
185    await update($, info, i => ({ ...i, ...now }))
186  }
187}
188
189export const register: Register = on => {
190  on('session.start', async ($, e, next) => {
191    await refresh($)
192    $.clock.every(1000, () => sync($))
193
194    return next(e)
195  })
196
197  on('turn.complete', async ($, e, next) => {
198    const result = await next(e)
199    await refresh($)
200
201    return result
202  })
203
204  on('ui.render', { component: 'PromptHint' }, ($, e) => {
205    const { Box } = $.ui.resolve(e)
206
207    return <Box />
208  })
209
210  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
211    const [i, open] = await Promise.all([read($, info), read($, menu)])
212
213    if (e.props.hasSurvey || i.window === 0) {
214      return next(e)
215    }
216
217    const below = await next(e)
218    const { Box, Button, Text } = $.ui.resolve(e)
219    const color = levelColor(i.percent, i.compactAt)
220    const left = [i.model, i.effort, i.dir, i.branch, i.mode].filter(Boolean)
221    const leftLength = left.reduce((n, s) => n + s.length + 3, 0)
222    const percent = ` ${i.percent}% `
223    const sizes = ` ${short(i.tokens)}/${short(i.window)}${i.compactAt > 0 ? ` compact ${i.compactAt}%` : ''}`
224    const barLength = Math.max(8, e.props.bodyColumns - leftLength - percent.length - sizes.length - 5)
225    const head = Math.min(barLength - 1, Math.round((i.percent / 100) * (barLength - 1)))
226    const line = i.compactAt > 0 ? Math.min(barLength - 1, Math.round((i.compactAt / 100) * (barLength - 1))) : null
227
228    return (
229      <Box flexDirection="column">
230      {open === 'model' ? (
231        <Box marginLeft={1} flexDirection="column" borderStyle="round" borderColor="gray" alignSelf="flex-start">
232          {MODELS.map(choice => (
233            <Button
234              key={`model-${choice.alias}`}
235              plain
236              dimColor={!i.model.toLowerCase().startsWith(choice.label.toLowerCase())}
237              label={`${i.model.toLowerCase().startsWith(choice.label.toLowerCase()) ? '●' : ' '} ${choice.label}`}
238              onPress={() => setModel($, choice)}
239            />
240          ))}
241        </Box>
242      ) : null}
243      {open === 'effort' ? (
244        <Box marginLeft={i.model.length + 6} flexDirection="column" borderStyle="round" borderColor="gray" alignSelf="flex-start">
245          {[...LEVELS].reverse().map(level => (
246            <Button
247              key={`level-${level}`}
248              plain
249              dimColor={level !== i.effort}
250              label={`${level === i.effort ? '●' : ' '} ${level}`}
251              onPress={() => setEffort($, level)}
252            />
253          ))}
254        </Box>
255      ) : null}
256      <Box>
257        {left.map((s, n) =>
258          n === 0 ? (
259            <Box key={n}>
260              <Text dimColor>{'▶ '}</Text>
261              <Button key="model" plain label={open === 'model' ? `${s} ▾` : `${s} ▴`} onPress={() => update($, menu, now => (now === 'model' ? null : 'model'))} />
262              <Text>{' '}</Text>
263            </Box>
264          ) : s === i.effort ? (
265            <Box key={n}>
266              <Text dimColor>{'▶ '}</Text>
267              <Button key="effort" plain dimColor label={open === 'effort' ? `${s} ▾` : `${s} ▴`} onPress={() => update($, menu, now => (now === 'effort' ? null : 'effort'))} />
268              <Text>{' '}</Text>
269            </Box>
270          ) : (
271            <Text key={n}>
272              <Text dimColor>{'▶ '}</Text>
273              <Text bold={n === 0} color={n === left.length - 1 && i.mode ? 'yellow' : undefined}>{s}</Text>
274              <Text>{' '}</Text>
275            </Text>
276          ),
277        )}
278        <Text color={color} bold>{percent}</Text>
279        {runs(barLength, head, line).map((run, n) => (
280          <Text key={n} color={run.kind === 'line' ? 'cyan' : run.kind === 'rest' ? undefined : color} dimColor={run.kind === 'rest'} bold={run.kind === 'line'}>
281            {run.text}
282          </Text>
283        ))}
284        <Text dimColor>{sizes}</Text>
285      </Box>
286        {below ?? null}
287      </Box>
288    )
289  })
290}
291
types/index.d.ts 19 lines
1export type Info = {
2  model: string
3  effort: string
4  dir: string
5  branch: string
6  mode: string
7  tokens: number
8  window: number
9  percent: number
10  // The percent of the window where auto-compact starts; 0 when no setting names it.
11  compactAt: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'context-bar': { info: Info; menu: 'effort' | 'model' | null }
17  }
18}
19