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

A Claude Code mod that steps the main thread's reasoning effort from the keyboard and shows it in the prompt footer.
effort ▰▰▰▱▱ high, gray at low through green, yellow and orange to red at max. A * marks a level picked with the keys./effort, a level change from Alt+P, or switching models drops the pick.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.
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.
macos-option-as-alt = left; Zed: "terminal": { "option_as_meta": true }; iTerm2: Left Option key set to Esc+).~/.claude/keybindings.json (a binding in another context does not reach the mod): { "context": "Global", "bindings": { "meta+k": "app:diffFileListUp", "meta+j": "app:diffFileListDown" } }
/diff panel lists more files than it shows, the keys scroll it instead./effort saves a level.CLAUDE_CODE_EFFORT_LEVEL, else the level saved for the model under modelSettings, else —.Built against Claude Code 2.1.300. A mod is an early-access API that may change between releases.
claude plugin validate .
claude plugin test .
tsc -p .hooks/register.tsx 120 lines1import { 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}
120types/index.d.ts 16 lines1export 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