Shows the selected model and observed main-thread effort in a colored band above the prompt

A colored band directly above the Claude Code prompt shows the selected model and the effort observed on the latest main-conversation request.
╭────────────────────────────────────────────────────────────╮
│ Haiku 4.5 │ effort ▰▱▱▱▱ low · 빠르게 Haiku │
╰────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────╮
│ Sonnet 4.6 │ effort ▰▰▱▱▱ medium · 보통 Sonnet │
╰────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────╮
│ Opus 4.6 │ effort ▰▰▰▱▱ high · 깊게 Opus │
╰────────────────────────────────────────────────────────────╯
These are text illustrations of three themes; one band appears at a time. Haiku uses green, Sonnet blue, Opus purple. Displayed versions come from the model ID, so an alias such as opus displays Opus without inventing a version. Unknown/custom model names use a neutral theme.
/plugin marketplace add YeonwooSung/my-claude-code-mods
/plugin install model-effort-band@my-claude-code-mods
Or load the directory for one session from this repository:
claude --plugin-dir ./model-effort-band
Mods load by default starting with Claude Code 2.1.287. This mod's event and render API combination is tested with 2.1.292. Update an older CLI before loading it. Disable the mod through /plugin when you want to hide the band; there are no extra commands or settings.
low, medium, high, xhigh, max fill one through five gauge cells. The Korean captions describe effort, not measured speed or answer quality.turn.step observed by this mod. No defaults are guessed from model family or settings.unknown until observed again. Any successful configuration change clears observation; so does /model, /effort, or /config, including a cancelled picker.unknown. This includes alias/resolved-ID mismatches and request fallbacks. This band shows the selected model, not a history of actual request models.The band has a rounded border, model label on the left, effort beside it, and a family badge at the right. At fewer than 60 columns it omits the gauge, caption, and badge. Below 30 columns or with fewer than three available rows it uses one unbordered line. Text truncates rather than wrapping. A survey takes precedence and hides the band. Other mods' AbovePrompt content is preserved before this band; the engine manages scrolling of combined content.
Terminal and Desktop support the render site. VS Code chat and headless mode have no visual band. There are no model requests, prompt changes, file writes, network calls, polling timers, or persistent state in this mod.
From the repository root:
npm test
CLAUDE_BIN=/path/to/claude-2.1.292 npm run test:adapters
npm run typecheck
/path/to/claude-2.1.292 plugin validate model-effort-band
Typecheck requires the local engine-generated .claude-plugin/types declarations, as with the other mods. If using this repository's existing 2.1.292 declarations, copy them from agent-profiler/.claude-plugin/types to this mod's .claude-plugin/types; declarations remain ignored by Git.
Tests cover normalization, state lifetime, stream chunks and final return values, settings denial, model-lookup and invalidation failures, surveys, model themes, and compact layouts. Terminal and Desktop render trees are tested; live terminal pixels and the Desktop app have not been visually verified. A live terminal check stopped at the engine's workspace trust prompt without changing trust settings.
hooks/register.tsx 92 lines1import type { EngineInterface, Register } from 'claude-code'
2import { BandState, bandView } from './core/band.ts'
3
4function repaint($: EngineInterface): void {
5 try { $.ui.invalidate('ui.render') } catch { /* Drawing must not interrupt a request. */ }
6}
7
8// Known model labels are ASCII; Korean descriptions take two terminal cells each.
9function cells(text: string): number {
10 return Array.from(text).reduce((total, char) => total + (/[^\u0000-\u007f\u25b0\u25b1]/u.test(char) ? 2 : 1), 0)
11}
12
13export const register: Register = on => {
14 const state = new BandState()
15
16 on('session.start', async ($, e, next) => { state.reset(); repaint($); return next(e) })
17 on('session.end', async ($, e, next) => { state.reset(); repaint($); return next(e) })
18 on('turn.start', async ($, e, next) => {
19 state.reset(); repaint($)
20 return next(e)
21 })
22 on('turn.step', async function* ($, e, next) {
23 if (e.agentId === undefined) { state.observeStep(e); repaint($) }
24 return yield* next(e)
25 })
26 on('classic.PostModelSwitch', async ($, e, next) => {
27 state.reset(); repaint($)
28 return next(e)
29 })
30 on('config.set', async ($, e, next) => {
31 const result = await next(e)
32 if (result.deny === undefined) { state.reset(); repaint($) }
33 return result
34 })
35 on('command.run', { command: /^(model|effort|config)$/ }, async ($, e, next) => {
36 const result = await next(e)
37 state.reset(); repaint($)
38 return result
39 })
40
41 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
42 const existing = await next(e)
43 if (e.props.hasSurvey) return existing
44 try {
45 let selected = ''
46 try { selected = await $.session.model() } catch { /* Render an honest unknown. */ }
47 const observed = state.snapshot(selected)
48 const view = bandView(observed.model, observed.effort, e.props.bodyColumns, e.props.maxRows)
49 if (view.layout === 'hidden') return existing
50 const { Box, Text } = $.ui.resolve(e)
51 const { model, effort } = view
52 const columns = Math.floor(e.props.bodyColumns)
53 if (view.layout === 'inline') {
54 return <Box flexDirection="column">
55 {existing}
56 <Box width={columns} flexShrink={0} overflow="hidden">
57 <Text color={model.color} wrap="truncate-end">{`${model.name} | effort ${effort.value}`}</Text>
58 </Box>
59 </Box>
60 }
61 const inner = columns - 4 // two borders and one padding cell at each edge
62 const full = view.layout === 'full'
63 const detail = full
64 ? `${effort.meter ? effort.meter + ' ' : ''}${effort.value}${effort.description ? ' · ' + effort.description : ''}`
65 : effort.value
66 const badgeWidth = full ? model.badge.length + 2 : 0
67 const effortWidth = Math.min(cells('effort ' + detail), Math.floor((inner - 3 - badgeWidth) * 0.7))
68 const nameWidth = Math.min(cells(model.name), inner - 3 - badgeWidth - effortWidth)
69 return <Box flexDirection="column">
70 {existing}
71 <Box width={columns} borderStyle="round" borderColor={model.color} backgroundColor={model.background}
72 paddingX={1} flexDirection="row" alignItems="center" overflow="hidden">
73 <Box width={nameWidth} flexShrink={0}>
74 <Text bold color={model.color} wrap="truncate-end">{model.name}</Text>
75 </Box>
76 <Text dimColor> │ </Text>
77 <Box width={effortWidth} flexShrink={0}>
78 <Text wrap="truncate-end"><Text dimColor>effort </Text><Text bold color={effort.color}>{detail}</Text></Text>
79 </Box>
80 {full ? <Box flexGrow={1} /> : null}
81 {full ? <Box width={badgeWidth} flexShrink={0} justifyContent="flex-end">
82 <Text bold color={model.color} backgroundColor={model.background}>{` ${model.badge} `}</Text>
83 </Box> : null}
84 </Box>
85 </Box>
86 } catch {
87 // next has already run: return its tree, never run it a second time.
88 return existing
89 }
90 })
91}
92hooks/core/band.ts 85 lines1export type Family = 'haiku' | 'sonnet' | 'opus' | 'unknown'
2export type ModelView = { name: string; family: Family; badge: string; color: string; background: string }
3export type EffortView = { value: string; meter: string; description: string; color: string }
4export type BandView = { model: ModelView; effort: EffortView; layout: 'full' | 'compact' | 'inline' | 'hidden' }
5
6const THEMES: Record<Family, { badge: string; color: string; background: string }> = {
7 haiku: { badge: 'Haiku', color: '#8ACFA7', background: '#17251F' },
8 sonnet: { badge: 'Sonnet', color: '#8BBEF4', background: '#192333' },
9 opus: { badge: 'Opus', color: '#B99CE5', background: '#241C32' },
10 unknown: { badge: 'Model', color: '#B5BAC4', background: '#202329' },
11}
12
13// Strip escape sequences before controls so their payload never becomes visible UI text.
14function clean(value: unknown): string {
15 if (typeof value !== 'string') return ''
16 const text = value
17 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
18 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
19 .replace(/[\x00-\x08\x0e-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, '')
20 .replace(/\s+/g, ' ').trim()
21 return Array.from(text).slice(0, 80).join('')
22}
23
24export function modelView(model: unknown): ModelView {
25 const raw = clean(model)
26 const id = raw.toLowerCase().replace(/^(?:(?:us|eu|apac|global)\.)?anthropic\./, '')
27 const alias = /^(haiku|sonnet|opus)$/.exec(id)
28 const suffix = '(?:-\\d{8})?(?:-v\\d+(?::\\d+)?|:v\\d+)?'
29 const modern = new RegExp(`^claude-(haiku|sonnet|opus)-(\\d{1,2})(?:-(\\d{1,2}))?${suffix}$`).exec(id)
30 const legacy = new RegExp(`^claude-(\\d{1,2})(?:-(\\d{1,2}))?-(haiku|sonnet|opus)${suffix}$`).exec(id)
31 const family = (alias?.[1] ?? modern?.[1] ?? legacy?.[3] ?? 'unknown') as Family
32 const major = modern?.[2] ?? legacy?.[1]
33 const minor = modern?.[3] ?? legacy?.[2]
34 const theme = THEMES[family]
35 const version = major ? ` ${major}${minor ? `.${minor}` : ''}` : ''
36 return { name: family === 'unknown' ? raw || 'Unknown model' : theme.badge + version, family, ...theme }
37}
38
39export function effortView(effort: unknown): EffortView {
40 const levels: Record<string, [string, string, string]> = {
41 low: ['▰▱▱▱▱', '빠르게', THEMES.haiku.color],
42 medium: ['▰▰▱▱▱', '보통', THEMES.sonnet.color],
43 high: ['▰▰▰▱▱', '깊게', '#E4CC75'],
44 xhigh: ['▰▰▰▰▱', '더 깊게', '#E4CC75'],
45 max: ['▰▰▰▰▰', '최대로', '#E4CC75'],
46 }
47 const value = typeof effort === 'number'
48 ? Number.isFinite(effort) && effort >= 0 ? String(effort) : ''
49 : clean(effort)
50 // Own-property lookup keeps arbitrary future strings such as "constructor" harmless.
51 const level = Object.hasOwn(levels, value) ? levels[value] : undefined
52 return {
53 value: value || 'unknown', meter: level?.[0] ?? '',
54 description: level?.[1] ?? (value ? '' : '확인 전'), color: level?.[2] ?? THEMES.unknown.color,
55 }
56}
57
58export function bandView(model: unknown, effort: unknown, columns: number, rows: number): BandView {
59 const width = Number.isFinite(columns) ? Math.floor(columns) : 0
60 const height = Number.isFinite(rows) ? Math.floor(rows) : 0
61 const layout = width <= 0 || height <= 0 ? 'hidden'
62 : width < 30 || height < 3 ? 'inline' : width < 60 ? 'compact' : 'full'
63 return { model: modelView(model), effort: effortView(effort), layout }
64}
65
66export class BandState {
67 private model: string | undefined
68 private effort: unknown
69
70 observeStep(step: { model: string; effort?: unknown; agentId?: string }): void {
71 if (step.agentId !== undefined) return
72 this.model = step.model
73 this.effort = step.effort
74 }
75
76 reset(): void {
77 this.model = undefined
78 this.effort = undefined
79 }
80
81 snapshot(selectedModel: string): { model: string; effort: unknown } {
82 return { model: selectedModel, effort: selectedModel === this.model ? this.effort : undefined }
83 }
84}
85