SLOPSHOPPER

bline

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

newspinner
v0.2.0MITupdated 2026-10-25xchacha20-poly1305/bline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bline
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › 🐱 ⟨Claude Code's own drawing⟩ context

Draws

Prompt hint
🐱 ⟨Claude Code's own drawing⟩ context
README

bline

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, left: the name /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.
  • Effort, in brackets: ultra, ultracode and max in rainbow colors, xhigh red, high orange, medium yellow, low green, any other value blue.
  • Context window, right: a 14-cell bar and the share used, green under 50%, yellow under 80%, red from 80%, where 🈵 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.

Screenshot

Install

From agent-plugins:

claude plugin marketplace add https://github.com/xchacha20-poly1305/agent-plugins.git
claude plugin install bline@anrong-plugins

License

MIT

Source 2 files
hooks/register.tsx 240 lines
1import { 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}
240
types/index.d.ts 14 lines
1export 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