SLOPSHOPPER

cc-effort-switch

Step the main thread's reasoning effort with Alt/Option+Up and Alt/Option+Down, shown in the prompt footer

newbandspinner
v0.2.0no licenseupdated 2026-10-15HuliLulu46/cc-effort-switch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-effort-switch
› 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⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits · effort ▱▱▱▱▱ —

Draws

Band
⟨Claude Code's own drawing⟩
README

cc-effort-switch

A Claude Code mod that steps the main thread's reasoning effort from the keyboard and shows it in the prompt footer.

  • Option+↑ / Option+↓ (or Ctrl+↑ / Ctrl+↓) step the level: low, medium, high, xhigh, max.
  • A colored meter in the footer: effort ▰▰▰▱▱ high, gray at low through green, yellow and orange to red at max. A * marks a level picked with the keys.
  • Claude Code's own controls win: /effort, a level change from Alt+P, or switching models drops the pick.

Install

At the prompt of a Claude Code terminal session:

/plugin install cc-effort-switch --marketplace HuliLulu46/cc-effort-switch

Answer y to add the marketplace, then pick a scope (user is first).

From a shell:

claude plugin install cc-effort-switch --marketplace HuliLulu46/cc-effort-switch

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with HuliLulu46/cc-effort-switch, then Add plugin.

Keys

There is nothing to bind. The mod borrows Claude Code's app:diffFileListUp and app:diffFileListDown actions, which are bound to Ctrl+↑ / Alt+↑ and Ctrl+↓ / Alt+↓ by default and do nothing at the prompt.

  • On macOS, Option+↑ reaches Claude Code as Alt+↑ only when the terminal sends Option as Meta (Ghostty: macos-option-as-alt = left; Zed: "terminal": { "option_as_meta": true }; iTerm2: Left Option key set to Esc+).
  • Ctrl+↑ and Ctrl+↓ belong to Mission Control by default; turn them off in System Settings → Keyboard → Keyboard Shortcuts → Mission Control to use them here.
  • To use other keys, bind them to the same two actions in the Global context of ~/.claude/keybindings.json (a binding in another context does not reach the mod):
  { "context": "Global", "bindings": { "meta+k": "app:diffFileListUp", "meta+j": "app:diffFileListDown" } }
  • While the /diff panel lists more files than it shows, the keys scroll it instead.

Known limits

  • The pick applies to the main thread's requests; subagents keep their own effort.
  • A pick lasts for the session: a mod cannot write settings, so it is not saved the way /effort saves a level.
  • Before the first request the footer shows CLAUDE_CODE_EFFORT_LEVEL, else the level saved for the model under modelSettings, else —.
  • Claude Code's own effort indicators show the level they know; the footer meter shows the level requests go out with.

Built against Claude Code 2.1.300. A mod is an early-access API that may change between releases.

Develop

claude plugin validate .
claude plugin test .
tsc -p .
Source 2 files
hooks/register.tsx 120 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey } from 'claude-code'
3
4import type { Effort } from '../types'
5
6const LEVELS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
7// Each level's color by theme key, so it follows the person's theme: cool to hot.
8const HEAT: Record<Effort, ThemeKey> = { low: 'inactive', medium: 'success', high: 'warning', xhigh: 'claude', max: 'error' }
9const WIDEST = Math.max(...LEVELS.map(level => level.length))
10
11const override = atom({ plugin: 'cc-effort-switch', key: 'override' } as const, null)
12const base = atom({ plugin: 'cc-effort-switch', key: 'base' } as const, null)
13
14const isLevel = (value: unknown): value is Effort => LEVELS.includes(value as Effort)
15
16// The engine's level for a model before its first request: CLAUDE_CODE_EFFORT_LEVEL, else the level
17// /effort or Alt+P saved for it under modelSettings. Unknown otherwise until a request carries one.
18async function startingLevel($: EngineInterface, model: string): Promise<Effort | null> {
19  const env = (await $.env.get('CLAUDE_CODE_EFFORT_LEVEL'))?.trim().toLowerCase()
20  if (isLevel(env)) return env
21  const settings = await $.settings.read()
22  const saved = settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined
23  const level = saved?.[model.replace(/\[.*\]$/, '')]?.effortLevel
24  return isLevel(level) ? level : null
25}
26
27async function shownLevel($: EngineInterface, model: string): Promise<Effort | null> {
28  const picked = await read($, override)
29  if (picked?.model === model) return picked.level
30  const held = await read($, base)
31  if (held?.model === model) return held.level
32  return startingLevel($, model)
33}
34
35// Two presses close together make two steps: each reads the level the last one wrote.
36let queue: Promise<unknown> = Promise.resolve()
37function step($: EngineInterface, by: 1 | -1) {
38  const run = queue.then(async () => {
39    const model = await $.session.model()
40    const from = await shownLevel($, model)
41    const index = from === null ? LEVELS.indexOf('high') : LEVELS.indexOf(from) + by
42    const level = LEVELS[Math.min(LEVELS.length - 1, Math.max(0, index))] ?? 'high'
43    await update($, override, () => ({ model, level }))
44  })
45  queue = run.catch(() => undefined)
46  return run
47}
48
49export const register: Register = on => {
50  on('turn.step', async function* ($, e, next) {
51    if (e.agentId !== undefined || !isLevel(e.effort)) return yield* next(e)
52
53    const held = await read($, base)
54    if (held?.model !== e.model || held.level !== e.effort) {
55      // The engine changed its level on this model (Alt+P): its change wins over the keys' pick.
56      if (held?.model === e.model) await update($, override, () => null)
57      await update($, base, () => ({ model: e.model, level: e.effort as Effort }))
58    }
59    const picked = await read($, override)
60
61    return yield* next(picked?.model === e.model ? { ...e, effort: picked.level } : e)
62  })
63
64  on('command.run', { command: 'effort' }, async ($, e, next) => {
65    const ran = await next(e)
66    await update($, override, () => null)
67    const level = e.args.trim()
68    if (isLevel(level)) {
69      const model = await $.session.model()
70      await update($, base, () => ({ model, level }))
71    }
72
73    return ran
74  }).catch(($, e, next) => next(e))
75
76  // The engine's modes stay as it draws them, then a meter: one cell per level, filled up to the
77  // current one in its heat color, and the level's word padded so the footer does not shift.
78  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
79    const { Box, Text } = $.ui.resolve(e)
80    const level = await shownLevel($, await $.session.model())
81    const isPicked = (await read($, override)) !== null
82    const filled = level === null ? 0 : LEVELS.indexOf(level) + 1
83    const color = level === null ? undefined : HEAT[level]
84
85    return (
86      <Box>
87        {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} · </Text>}
88        <Text dimColor>effort </Text>
89        {LEVELS.map((_, i) =>
90          i < filled ? <Text color={color}>▰</Text> : <Text dimColor>▱</Text>,
91        )}
92        <Text color={color} dimColor={level === null} weight={level === 'max' ? 'bold' : undefined}>
93          {' '}
94          {(level ?? '—').padEnd(WIDEST)}
95        </Text>
96        <Text dimColor>{isPicked ? '*' : ' '}</Text>
97      </Box>
98    )
99  })
100
101  // A mod reaches a key only through a Button naming an engine keybinding action, pressed by the
102  // action's Global bindings alone. The diff panel's file-list actions idle at the prompt and are
103  // bound to Ctrl/Alt+Up and Ctrl/Alt+Down by default; keybindings.json adds more keys to them in
104  // Global. The Buttons sit hidden beside whatever the band already draws.
105  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
106    const below = await next(e)
107    const { Box, Button } = $.ui.resolve(e)
108
109    return (
110      <Box flexDirection="column">
111        {below}
112        <Box display="none">
113          <Button key="effort-up" label="effort up" action="app:diffFileListUp" onPress={() => step($, 1)} />
114          <Button key="effort-down" label="effort down" action="app:diffFileListDown" onPress={() => step($, -1)} />
115        </Box>
116      </Box>
117    )
118  })
119}
120
types/index.d.ts 16 lines
1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2
3/** One model's effort level. */
4export type EffortPick = { model: string; level: Effort }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'cc-effort-switch': {
9      /** The level the keys picked for a model, sent on its main-loop requests; null follows the engine. */
10      override: EffortPick | null
11      /** The engine's own level on the model's last main-loop request, before the override. */
12      base: EffortPick | null
13    }
14  }
15}
16