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

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

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.
hooks/register.tsx 291 lines1import { 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}
291types/index.d.ts 19 lines1export 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