SLOPSHOPPER

model-effort-switcher

Shows the model and effort under the prompt, cycled with alt+up/down (model), alt+left/right (effort) and alt+. (ultracode)

newbandspinnercommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-effort-switcher
› 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 › /model-effort-switcher ⎿ model-effort-switcher: keys (bound in ~/.claude/keybindings.json): ⎿ model-effort-switcher: alt+↑ / alt+↓ previous / next model (runs /model) ⎿ model-effort-switcher: alt+← / alt+→ lower / higher effort (runs /effort) ⎿ model-effort-switcher: alt+. ultracode on / off (/effort ultracode) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits & Opus 5.5 ϟϟϟ·· high

Draws

Band
⟨Claude Code's own drawing⟩
README

model-effort-switcher

A Claude Code mod that lets you switch model, effort and ultracode with one keystroke, and shows the current model and effort in the footer.

model-effort-switcher demo: cycling model, effort and ultracode with the keyboard

Why

Changing model or effort means typing /model sonnet or /effort high, or opening a picker. That is slow enough that you stop doing it, so you run everything at one setting. This mod makes it a keypress: drop to low effort for a quick edit, push to max for a hard bug, jump back.

The footer meter (ϟϟϟ·· high: one bolt per effort level out of five, one color per level, with xhigh shimmering and max cycling a rainbow) means you always see what you're running at.

Install

Needs a Claude Code version with mods (built on 2.1.289). Pick one:

Paste this into a Claude Code session and it does the rest:

Install the Claude Code mod from github.com/PedroLaRosa/claude-code:
1. Run `claude plugin marketplace add PedroLaRosa/claude-code`, then
   `claude plugin install model-effort-switcher@pedro-la-rosa-claude-code`.
2. Merge these into the "Global" bindings of ~/.claude/keybindings.json (create the
   file if missing, keep every existing binding, don't overwrite a key that is already
   bound; tell me about any conflict):
   alt+up → strip:jump6, alt+down → strip:jump7, alt+left → strip:jump8,
   alt+right → strip:jump9, alt+. → strip:jump5
3. Tell me to run /reload-plugins.
claude plugin marketplace add PedroLaRosa/claude-code
claude plugin install model-effort-switcher@pedro-la-rosa-claude-code

Or from inside a session:

/plugin marketplace add PedroLaRosa/claude-code
/plugin install model-effort-switcher@pedro-la-rosa-claude-code
/reload-plugins

Then bind the keys with one command (a mod can't ship keybindings, so it writes them for you):

/model-effort-switcher setup

It merges five keys into ~/.claude/keybindings.json and never overwrites a key you already bound to something else.

Last step: make your terminal send Alt. On macOS, Option usually types a symbol instead of acting as Alt. Turn on "Option as Alt" in your terminal (iTerm2: Profiles → Keys; Terminal.app: Use Option as Meta key; kitty: macos_option_as_alt yes). To change only alt+. in kitty, leave that setting off and add map opt+period send_text all \x1b. to kitty.conf.

Update with claude plugin update model-effort-switcher@pedro-la-rosa-claude-code. Remove with claude plugin uninstall model-effort-switcher@pedro-la-rosa-claude-code (and delete the five keys from keybindings.json).

Keys

KeyAction
alt+↑ / alt+↓previous / next model (runs /model)
alt+← / alt+→lower / higher effort (runs /effort, wraps)
alt+.ultracode on / off (runs /effort ultracode)

/model-effort-switcher prints this table inside Claude Code; it also shows in /help and the / menu.

  • Effort changes run /effort <level> with the level the footer shows, same as typing it.
  • Model changes run /model, so the new model becomes your saved default, same as typing it.
  • Ultracode needs dynamic workflows enabled and a model that supports it. If Claude Code refuses, the press does nothing; /effort ultracode on shows why.
  • These keys replace Claude Code's defaults for alt+↑/↓ (diff file list) and alt+←/→ (word jumps in the prompt).

Hack on it

Clone it and load the folder directly instead of installing:

git clone https://github.com/PedroLaRosa/claude-code ~/claude-code
claude --plugin-dir ~/claude-code/mods/model-effort-switcher

To load it in every session, add "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code/mods/model-effort-switcher" } to ~/.claude/settings.json. Saving a file reloads the mod.

  • Meter glyphs: edit ϟ (filled) and · (empty) in the SessionMode render hook of hooks/register.tsx. If ϟ renders as a letter in your font, swap it for ↯.
  • Model list: edit MODELS at the top of hooks/register.tsx (fable, opus, sonnet, haiku).
  • Different keys: change them in keybindings.json (keep the strip:jump5-9 actions), and update BINDINGS, SHORTCUTS and SHORTCUT_TABLE in hooks/register.tsx.
  • Check your change: claude plugin validate . and claude plugin test .

How it works

Mods can't read keys directly. Instead the mod draws five hidden buttons above the prompt, each tied to a built-in strip:jump action, and your keybindings fire those actions. Each effort step runs /effort <level>. A press mid-turn runs /effort once the turn ends; until then the picked level is written onto the turn's remaining main-thread requests (subagents keep theirs).

Claude Code gives mods no way to read its effort, so the footer follows the line /effort and the model picker print (Set effort level to max (this session only), … with high effort). Typing a level, the slider, its s for this session only, and Esc all show up straight away. Picking a level there replaces a key's pick.

What it runs and writes

  • Slash commands: alt+←/→ runs /effort <level>, alt+. runs /effort ultracode on|off, and alt+↑/↓ runs /model <name>, once per key press (after the turn ends, if a turn is running). It runs nothing else, and nothing without a key press.
  • Files: only /model-effort-switcher setup writes a file: it adds the five keys to ~/.claude/keybindings.json (found through HOME). It writes nothing when every key is already bound. Nothing is sent off your machine.

Limits

  • Plain shift+arrow can't be used: Claude Code doesn't deliver shift-only keys to mod buttons.
  • If Claude Code refuses an alt+. (ultracode not available), the next press may look like a no-op while it catches up. Claude Code's own · ultracode indicator above the prompt is always accurate.
Source 2 files
hooks/register.tsx 258 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Settings } from 'claude-code'
3import type { Level } from '../types'
4
5// Footer, right corner: `Opus 5.5 ϟϟϟ·· high`, model, meter and word in the level's color, xhigh shimmering, max cycling a rainbow.
6// alt+up / alt+down cycle the model (through /model), alt+left / alt+right the
7// effort (through /effort), alt+. toggles ultracode (`/effort ultracode on|off`). A key
8// reaches a mod only through a Button in the band above the prompt naming an engine
9// action, so ~/.claude/keybindings.json binds the keys to strip:jump5-9 (idle at the
10// prompt) and hidden Buttons there take them. Shift alone never presses a mod's Button.
11
12// ponytail: fixed list; edit it when your account's models change
13const MODELS = ['fable', 'opus', 'sonnet', 'haiku']
14const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
15const HEAT: Record<string, string> = { low: '#D7D787', medium: '#AED75F', high: '#87D7FF', xhigh: '#AF87FF' }
16// Max cycles these, each character one step ahead of the one before, a step every STEP_MS.
17const RAINBOW = ['#D7005F', '#FFAF5F', '#D7D787', '#AED75F', '#87D7FF', '#87AFFF', '#AF87FF']
18// Xhigh sweeps one light character along the string, then rests SHIMMER_REST steps.
19const SHIMMER = '#DEC8FF'
20const SHIMMER_REST = 3
21const STEP_MS = 110
22
23// The keys' pick, until the /effort row it ran prints. A press mid-turn waits for the
24// turn to end to run /effort, so meanwhile the pick goes on that model's main-thread requests.
25const pick = atom({ plugin: 'model-effort-switcher', key: 'pick' } as const, null)
26// The level the engine itself last sent on the main thread.
27const base = atom({ plugin: 'model-effort-switcher', key: 'base' } as const, null)
28// The level /effort or the model picker last printed. The engine keeps one for every
29// model; null after `auto`, so the saved default applies.
30const printed = atom({ plugin: 'model-effort-switcher', key: 'printed' } as const, null)
31// Whether ultracode is on, as /effort last printed it or the key last asked.
32// ponytail: a refused `on` from the key costs one press to resync; the engine draws the
33// truth itself (`· ultracode` above the prompt)
34const ultra = atom({ plugin: 'model-effort-switcher', key: 'ultra' } as const, false)
35
36// What /effort and the model picker print as they set a level: `Set effort level to max
37// (this session only): …`, `Effort level set to auto`, `…; set to 'xhigh' instead`,
38// `Set model to Opus 5.5 … with high effort`.
39const SET_LEVEL = /effort level (?:set )?to (\w+)|set to '(\w+)' instead|with (\w+) effort/i
40
41// The model the footer last drew, so the band can tell when alt+p changed it.
42let drawnModel = ''
43// Whether the footer last drew xhigh or max, the levels that animate.
44let animated = false
45
46const wrap = (list: string[], at: number, dir: number) => list[(at + dir + list.length) % list.length]!
47
48// The engine's level for `model`: its last request's (`seen`), else what /effort last
49// printed (`said`), else the saved default, read from settings only then.
50async function engineLevel(model: string, seen: Level | null, said: string | null, settings: () => Promise<Settings>): Promise<string> {
51  if (seen?.model === model) return seen.level
52  if (said !== null) return said
53  const s = await settings()
54  const perModel = s.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined
55  const saved = perModel?.[model.replace('[1m]', '')]?.effortLevel ?? s.effortLevel
56  return typeof saved === 'string' ? saved : 'high'
57}
58
59// Each press runs `/effort <level>` with the level the footer now shows, as typing it does.
60async function stepEffort($: EngineInterface, dir: number) {
61  const model = await $.session.model()
62  const engine = await engineLevel(model, await read($, base), await read($, printed), () => $.settings.read())
63  const now = await read($, pick)
64  const level = wrap(LEVELS, LEVELS.indexOf(now?.model === model ? now.level : engine), dir)
65  await update($, pick, () => ({ model, level }))
66  await $.command.run({ command: 'effort', args: level })
67}
68
69// The press notes its ask itself; the row /effort prints, if it reaches the hook, agrees.
70async function toggleUltra($: EngineInterface) {
71  const isUltra = !(await read($, ultra))
72  await update($, ultra, () => isUltra)
73  await $.command.run({ command: 'effort', args: `ultracode ${isUltra ? 'on' : 'off'}` })
74}
75
76// /model saves the pick as the default and leaves a row, as typing it does;
77// a press mid-turn runs once the turn ends.
78async function stepModel($: EngineInterface, dir: number) {
79  const now = (await $.session.model()).toLowerCase()
80  await $.command.run({ command: 'model', args: wrap(MODELS, MODELS.findIndex(m => now.includes(m)), dir) })
81  $.ui.invalidate('ui.render')
82}
83
84// claude-opus-5-5 → Opus 5.5, claude-haiku-4-5-20251001 → Haiku 4.5, …[1m] → … 1M.
85function displayName(model: string): string {
86  const [family, ...version] = model.replace('[1m]', '').replace(/^claude-/, '').split('-').filter(p => !/^\d{8}$/.test(p))
87  if (!family) return model
88  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join('.')}`.trim() + (model.endsWith('[1m]') ? ' 1M' : '')
89}
90
91// The `?` shortcuts list takes no plugin rows, so the keys live in /help and the
92// typeahead as /model-effort-switcher's description, and in full as its output.
93// ponytail: copied from ~/.claude/keybindings.json; edit both when a key moves
94const SHORTCUTS = 'alt+↑/↓ model · alt+←/→ effort · alt+. ultracode'
95const SHORTCUT_TABLE = [
96  'keys (bound in ~/.claude/keybindings.json):',
97  '  alt+↑ / alt+↓   previous / next model (runs /model)',
98  '  alt+← / alt+→   lower / higher effort (runs /effort)',
99  '  alt+.           ultracode on / off (/effort ultracode)',
100].join('\n')
101
102const BINDINGS: Record<string, string> = {
103  'alt+up': 'strip:jump6',
104  'alt+down': 'strip:jump7',
105  'alt+left': 'strip:jump8',
106  'alt+right': 'strip:jump9',
107  'alt+.': 'strip:jump5',
108}
109
110type KeybindingsFile = { bindings?: { context: string; bindings: Record<string, string | null> }[] }
111
112// `/model-effort-switcher setup`: merges BINDINGS into ~/.claude/keybindings.json, leaving
113// any key the person already bound to something else alone.
114async function setupKeys($: EngineInterface): Promise<string> {
115  const path = `${await $.env.get('HOME')}/.claude/keybindings.json`
116  let file: KeybindingsFile = {}
117  if (await $.fs.exists(path)) {
118    try {
119      file = JSON.parse(await $.fs.read(path))
120    } catch {
121      return `${path} is not valid JSON, so nothing was changed. Fix it and run /model-effort-switcher setup again.`
122    }
123  }
124  const all = (file.bindings ??= [])
125  let global = all.find(b => b.context === 'Global')
126  if (!global) all.push((global = { context: 'Global', bindings: {} }))
127  const added: string[] = []
128  const kept: string[] = []
129  for (const [key, action] of Object.entries(BINDINGS)) {
130    const now = global.bindings[key]
131    if (now === undefined) {
132      global.bindings[key] = action
133      added.push(key)
134    } else if (now !== action) kept.push(`${key} (already ${now ?? 'unbound'})`)
135  }
136  if (added.length > 0) await $.fs.write(path, `${JSON.stringify(file, null, 2)}\n`)
137
138  return [
139    added.length > 0 ? `Bound ${added.join(', ')} in ~/.claude/keybindings.json.` : 'The keys are already bound.',
140    kept.length > 0 ? `Left alone: ${kept.join(', ')}. Unbind those, then run setup again.` : '',
141    'Last step: make your terminal send Alt for Option (see the README).',
142  ].filter(Boolean).join('\n')
143}
144
145export const register: Register = on => {
146  on('session.start', async ($, e, next) => {
147    await $.command.register({ name: 'model-effort-switcher', description: `Keys: ${SHORTCUTS} · "setup" binds them`, argumentHint: '[setup]' })
148
149    // ponytail: one standing timer; it only redraws while max is showing
150    $.clock.every(STEP_MS, () => {
151      if (animated) $.ui.invalidate('ui.render')
152    })
153
154    return next(e)
155  })
156
157  on('command.run', { command: 'model-effort-switcher' }, async ($, e) => ({
158    text: e.args.trim() === 'setup' ? await setupKeys($) : SHORTCUT_TABLE,
159  }))
160
161  on('turn.step', async function* ($, e, next) {
162    const level = e.effort
163    if (e.agentId !== undefined || typeof level !== 'string') return yield* next(e)
164    const seen = await read($, base)
165    if (seen?.model !== e.model || seen.level !== level) {
166      // The engine's level changed on this model without printing it: follow it.
167      if (seen?.model === e.model) await update($, pick, () => null)
168      await update($, base, () => ({ model: e.model, level }))
169    }
170    const chosen = await read($, pick)
171
172    return yield* next(chosen?.model === e.model ? { ...e, effort: chosen.level as typeof e.effort } : e)
173  })
174
175  // The row /effort or the model picker prints is the only place the engine tells a mod
176  // its level: typed or slid, saved or this session only. A row that sets none (Esc's
177  // `Cancelled`, an error) leaves the pick alone.
178  on('session.append', async ($, e, next) => {
179    const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
180    if (e.agentId !== undefined || !text.startsWith('<local-command-stdout>')) return next(e)
181    const set = text.match(SET_LEVEL)
182    const level = (set?.[1] ?? set?.[2] ?? set?.[3])?.toLowerCase()
183    if (level !== undefined && (level === 'auto' || LEVELS.includes(level))) {
184      await update($, pick, () => null)
185      await update($, base, () => null)
186      await update($, printed, () => (level === 'auto' ? null : level))
187    }
188    const ultracode = text.match(/\bUltracode (on|off)\b/)
189    if (ultracode) await update($, ultra, () => ultracode[1] === 'on')
190
191    return next(e)
192  }).catch(($, e, next) => next(e))
193
194  on('command.run', { command: 'model' }, async ($, e, next) => {
195    const ran = await next(e)
196    $.ui.invalidate('ui.render')
197
198    return ran
199  })
200
201  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
202    const model = await $.session.model()
203    drawnModel = model
204    const chosen = await read($, pick)
205    const level =
206      chosen?.model === model ? chosen.level : await engineLevel(model, await read($, base), await read($, printed), () => $.settings.read())
207    const filled = LEVELS.indexOf(level) + 1
208    animated = level === 'xhigh' || level === 'max'
209    const step = Math.floor((await $.clock.now()) / STEP_MS)
210    const { Box, Text } = $.ui.resolve(e)
211    const name = `${displayName(model)} `
212    const total = name.length + filled + level.length
213    // The level's color. Max: each character its own rainbow step; xhigh: one light
214    // character sweeping along. Both run on from `from`, so the model, meter and word
215    // move as one string.
216    const color = (at: number) =>
217      level === 'max'
218        ? RAINBOW[(step + at) % RAINBOW.length]
219        : at === step % (total + SHIMMER_REST) ? SHIMMER : HEAT[level]
220    const paint = (text: string, from: number) =>
221      animated
222        ? [...text].map((c, i) => <Text key={i} color={color(from + i)}>{c}</Text>)
223        : <Text color={HEAT[level]}>{text}</Text>
224
225    return (
226      <Box>
227        {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} & </Text>}
228        {paint(name, 0)}
229        {paint('ϟ'.repeat(filled), name.length)}
230        <Text dimColor>{'·'.repeat(LEVELS.length - filled)}</Text>
231        <Text> </Text>
232        {paint(level, name.length + filled)}
233      </Box>
234    )
235  })
236
237  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
238    // An alt+p pick tells a mod nothing, but this band redraws as the picker closes.
239    if ((await $.session.model()) !== drawnModel) $.ui.invalidate('ui.render')
240    // The band is shared: what draws there beneath stays, the Buttons hide beside it.
241    const below = await next(e)
242    const { Box, Button } = $.ui.resolve(e)
243
244    return (
245      <Box flexDirection="column">
246        {below}
247        <Box display="none">
248          <Button key="model-next" label="model next" action="strip:jump7" onPress={() => stepModel($, 1)} />
249          <Button key="model-prev" label="model previous" action="strip:jump6" onPress={() => stepModel($, -1)} />
250          <Button key="effort-up" label="effort up" action="strip:jump9" onPress={() => stepEffort($, 1)} />
251          <Button key="effort-down" label="effort down" action="strip:jump8" onPress={() => stepEffort($, -1)} />
252          <Button key="ultracode" label="ultracode" action="strip:jump5" onPress={() => toggleUltra($)} />
253        </Box>
254      </Box>
255    )
256  })
257}
258
types/index.d.ts 9 lines
1// A level of effort, tied to the model it was seen on or picked for.
2export type Level = { model: string; level: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'model-effort-switcher': { pick: Level | null; base: Level | null; printed: string | null; ultra: boolean }
7  }
8}
9