Shows the model, its effort and the context window fill on the line under the prompt

A Claude Code mod that puts the model, its reasoning effort and the context window fill on the hint line under the prompt:
🐱 opus-5-5⚡ (medium) ██░░░░░░░░░░░░ (15% of 1M)
/model shows, a leading claude- dropped, colored and marked by family: mythos 🐉 red, fable 📖 gold, opus 🐱 blue, sonnet 🎵 pink, haiku 🥺 green; any other model in the default color with no mark. ⚡ follows it while fast mode (the fastMode setting) is on.🈵 shows before the bar.Each part is clickable: the model runs /model, the effort /effort, and the context bar /usage, the Usage tab of /status. A click during a turn runs once the turn ends.

From agent-plugins:
claude plugin marketplace add https://github.com/xchacha20-poly1305/agent-plugins.git
claude plugin install bline@anrong-plugins
MIT
hooks/register.tsx 240 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, Settings } from 'claude-code'
3
4import type { ContextFill, Effort } from '../types'
5
6const effortAtom = atom({ plugin: 'bline', key: 'effort' } as const, null)
7const contextAtom = atom({ plugin: 'bline', key: 'context' } as const, null)
8
9const BAR_CELLS = 14
10
11type ModelFamily = { part: string; color: string; emoji: string }
12
13// Checked in order, so a name that contains two families takes the first.
14const MODEL_FAMILIES: readonly ModelFamily[] = [
15 { part: 'mythos', color: '#FF4040', emoji: '🐉' },
16 { part: 'fable', color: '#FFD700', emoji: '📖' },
17 { part: 'opus', color: '#9DC1FF', emoji: '🐱' },
18 { part: 'sonnet', color: '#FF79C6', emoji: '🎵' },
19 { part: 'haiku', color: '#9AE65C', emoji: '🥺' },
20]
21
22const EFFORT_COLORS: Readonly<Record<string, string>> = {
23 xhigh: '#FF4040',
24 high: '#FF9A3C',
25 medium: '#FFD84D',
26 low: '#5FD75F',
27}
28const EFFORT_OTHER_COLOR = '#A8D4FF'
29const RAINBOW_EFFORTS = new Set(['ultra', 'ultracode', 'max'])
30const RAINBOW = ['#FF4040', '#FF9A3C', '#FFD84D', '#5FD75F', '#5FAFFF', '#8C7CFF', '#E070FF']
31
32// From this much of the window used, the context counts as nearly full.
33const NEARLY_FULL_PERCENT = 80
34const NEARLY_FULL_MARK = '🈵'
35
36// The fill's color by how much of the window is used, the first bound it is under.
37const FILL_COLORS: ReadonlyArray<readonly [number, string]> = [
38 [50, '#5FD75F'],
39 [NEARLY_FULL_PERCENT, '#FFD84D'],
40 [Infinity, '#FF4040'],
41]
42
43function fillColor(percent: number): string | undefined {
44 return FILL_COLORS.find(([bound]) => percent < bound)?.[1]
45}
46
47// What a click on each part runs; `/usage` opens the `/status` dialog on its Usage tab.
48const MODEL_COMMAND = 'model'
49const EFFORT_COMMAND = 'effort'
50const USAGE_COMMAND = 'usage'
51
52const MODEL_PREFIX = 'claude-'
53const FAST_MARK = '⚡'
54
55function displayModel(model: string): string {
56 return model.startsWith(MODEL_PREFIX) ? model.slice(MODEL_PREFIX.length) : model
57}
58
59function modelFamily(model: string): ModelFamily | undefined {
60 const name = model.toLowerCase()
61
62 return MODEL_FAMILIES.find(({ part }) => name.includes(part))
63}
64
65function formatTokens(tokens: number): string {
66 const units: ReadonlyArray<readonly [number, string]> = [
67 [1_000_000_000, 'B'],
68 [1_000_000, 'M'],
69 [1_000, 'K'],
70 ]
71
72 for (const [size, suffix] of units) {
73 if (tokens >= size) {
74 const value = tokens / size
75 const digits = Number.isInteger(value) || value >= 100 ? 0 : 1
76
77 return `${Number(value.toFixed(digits))}${suffix}`
78 }
79 }
80
81 return String(tokens)
82}
83
84// Keeps the figures alone: the state holds plain data the contract declares.
85function toFill({ tokens, window, percent }: SessionContextUsage): ContextFill {
86 return { tokens, window, percent }
87}
88
89function usedPercent(context: ContextFill): number {
90 if (context.percent !== undefined) {
91 return context.percent
92 }
93
94 if (context.tokens === undefined || context.window === 0) {
95 return 0
96 }
97
98 return Math.round((context.tokens / context.window) * 100)
99}
100
101const EFFORT_SETTING = 'effortLevel'
102// The effort picker saves under `modelSettings.<model id>`, over the top-level one.
103const MODEL_SETTINGS = 'modelSettings'
104
105function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
106 return typeof value === 'object' && value !== null
107}
108
109function toEffort(value: unknown): Effort | null {
110 return typeof value === 'string' || typeof value === 'number' ? value : null
111}
112
113function settingsEffort(settings: Settings, model: string): Effort | null {
114 const perModel = settings[MODEL_SETTINGS]
115 const modelEntry = isRecord(perModel) ? perModel[model] : undefined
116 const modelEffort = isRecord(modelEntry) ? toEffort(modelEntry[EFFORT_SETTING]) : null
117
118 return modelEffort ?? toEffort(settings[EFFORT_SETTING])
119}
120
121async function readSettingsEffort($: EngineInterface): Promise<Effort | null> {
122 return settingsEffort(await $.settings.read(), await $.session.model())
123}
124
125export const register: Register = on => {
126 on('session.start', async ($, e, next) => {
127 const result = await next(e)
128 const effort = await readSettingsEffort($)
129 const { context } = await $.session.usage()
130 await update($, effortAtom, current => current ?? effort)
131 await update($, contextAtom, () => toFill(context))
132
133 return result
134 })
135
136 on('session.measure', async ($, e, next) => {
137 await update($, contextAtom, () => toFill(e.context))
138
139 return next(e)
140 })
141
142 // The effort a main-loop request is actually sent with is the truest reading.
143 on('turn.step', async function* ($, e, next) {
144 if (e.agentId === undefined) {
145 const effort = e.effort ?? null
146 await update($, effortAtom, () => effort)
147 }
148
149 return yield* next(e)
150 })
151
152 // Typed with an argument, `/effort` names the effort; bare, its picker
153 // saves to the settings, which the `settings.read` hook below picks up.
154 on('command.run', { command: EFFORT_COMMAND }, async ($, e, next) => {
155 const result = await next(e)
156 const typed = e.args.trim()
157
158 if (typed !== '') {
159 await update($, effortAtom, () => typed)
160 }
161
162 return result
163 })
164
165 // The engine merges the settings again whenever they change, so this sees
166 // the effort picker's pick however `/effort` was started, a click included.
167 on('settings.read', async ($, e, next) => {
168 const result = await next(e)
169
170 if (result.deny === undefined && next.origin.plugin === 'engine' && e.source === undefined) {
171 const effort = settingsEffort(result.value, await $.session.model())
172 await update($, effortAtom, current => effort ?? current)
173 }
174
175 return result
176 })
177
178 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
179 const engineLine = await next(e)
180 const model = displayModel(await $.session.model())
181 const family = modelFamily(model)
182 // `/fast` saves its toggle to this setting.
183 const isFast = (await $.settings.read({ key: 'fastMode' })) === true
184 const effort = await read($, effortAtom)
185 const context = await read($, contextAtom)
186 const { Box, Button, Text } = $.ui.resolve(e)
187
188 const effortLabel = effort === null ? null : String(effort)
189 const effortKey = effortLabel?.toLowerCase() ?? ''
190 const effortText =
191 effortLabel === null ? null : RAINBOW_EFFORTS.has(effortKey) ? (
192 [...effortLabel].map((char, index) => (
193 <Text color={RAINBOW[index % RAINBOW.length]}>{char}</Text>
194 ))
195 ) : (
196 <Text color={EFFORT_COLORS[effortKey] ?? EFFORT_OTHER_COLOR}>{effortLabel}</Text>
197 )
198
199 const percent = context === null ? null : usedPercent(context)
200 const filled = percent === null ? 0 : Math.round((Math.min(percent, 100) / 100) * BAR_CELLS)
201
202 // The engine's line refuses an ancestor that sizes it, so the layout lives
203 // in its siblings: the row stretches by default and a spacer takes the rest.
204 return (
205 <Box flexDirection="row">
206 <Box flexDirection="row" flexShrink={0} marginRight={2}>
207 <Button key="model" plain onPress={() => void $.command.run({ command: MODEL_COMMAND })}>
208 {family === undefined ? null : `${family.emoji} `}
209 <Text color={family?.color}>{model}</Text>
210 {isFast ? FAST_MARK : null}
211 </Button>
212 {effortText === null ? null : (
213 <Button key="effort" plain onPress={() => void $.command.run({ command: EFFORT_COMMAND })}>
214 {' ('}
215 {effortText}
216 {')'}
217 </Button>
218 )}
219 </Box>
220 {engineLine}
221 <Box flexGrow={1} />
222 {context === null || percent === null ? null : (
223 <Box flexShrink={0} marginLeft={2}>
224 <Button key="context" plain onPress={() => void $.command.run({ command: USAGE_COMMAND })}>
225 {percent >= NEARLY_FULL_PERCENT ? `${NEARLY_FULL_MARK} ` : null}
226 <Text color={fillColor(percent)}>{'█'.repeat(filled)}</Text>
227 <Text dimColor>{'░'.repeat(BAR_CELLS - filled)}</Text>
228 {' ('}
229 <Text color={fillColor(percent)}>{`${percent}%`}</Text>
230 {' '}
231 <Text dimColor>{`of ${formatTokens(context.window)}`}</Text>
232 {')'}
233 </Button>
234 </Box>
235 )}
236 </Box>
237 )
238 })
239}
240types/index.d.ts 14 lines1export type Effort = string | number
2
3/** The context window fill, as `$.session.usage()`'s `context` reports it. */
4export type ContextFill = { tokens?: number; window: number; percent?: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'bline': {
9 effort: Effort | null
10 context: ContextFill | null
11 }
12 }
13}
14