SLOPSHOPPER

model-effort-band

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

newband
★ 879v0.1.0no licenseupdated 2026-10-13YeonwooSung/my-claude-code-mods/model-effort-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-effort-band
› 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⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Opus 5.5 │ effort unknown · 확인 전 Opus │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Opus 5.5 │ effort unknown · 확인 전 Opus │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Model Effort Band

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.

Install

/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.

What the effort means

  • low, medium, high, xhigh, max fill one through five gauge cells. The Korean captions describe effort, not measured speed or answer quality.
  • The selected model is read on every render. Effort comes only from a main turn.step observed by this mod. No defaults are guessed from model family or settings.
  • Before a request, after a new turn/session/reload, or after model/settings changes, effort displays unknown until observed again. Any successful configuration change clears observation; so does /model, /effort, or /config, including a cancelled picker.
  • If the request's model differs from the selected model, effort stays unknown. This includes alias/resolved-ID mismatches and request fallbacks. This band shows the selected model, not a history of actual request models.
  • Subagent requests cannot replace main-conversation effort. Missing effort clears the old value. Numeric effort is shown literally without a gauge; future effort strings are shown safely without interpretation.
  • The observed request is the value at this mod's hook position. A later mod can still rewrite the request; the band does not verify that later rewrite.

Layout and surfaces

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.

Development and verification

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.

Source 2 files
hooks/register.tsx 92 lines
1import 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}
92
hooks/core/band.ts 85 lines
1export 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