Keyboard shortcuts that step the reasoning effort level and the model up and down; the footer shows what each request uses

A Claude Code plugin that changes the reasoning effort level and the model from the keyboard.
| Action | macOS | Other keyboards |
|---|---|---|
| Effort up: low, medium, high, xhigh, then back to low | Cmd+E or Option+E | Alt+E |
| Effort down, wrapping from low to xhigh | Cmd+Shift+E or Option+Shift+E | Alt+Shift+E |
| Next model: Haiku 5.5, Sonnet 5.5, Opus 5.5, Fable 5.1, then back to Haiku 5.5 | Cmd+P or Option+P | Alt+P |
| Previous model | Option+Shift+P | Alt+Shift+P |
Sonnet 5.5 · effort: high.Set to Sonnet 5.5 · effort: high. The model does not read it.claude plugin validate . passes on Claude Code 2.1.287. /plugin marketplace add richkuo/claude-code-model-effort-shortcuts
/plugin install model-effort-shortcuts@richkuo
~/.claude/keybindings.json: {
"bindings": [
{
"context": "Global",
"bindings": {
"meta+e": "strip:jump9",
"meta+shift+e": "strip:jump8",
"meta+p": "strip:jump7",
"meta+shift+p": "strip:jump6"
}
},
{
"context": "Chat",
"bindings": {
"meta+p": null
}
}
]
}
You can bind any key to the plugin's actions: strip:jump9 (effort up), strip:jump8 (effort down), strip:jump7 (next model), and strip:jump6 (previous model).
Option+P opens the model picker by default. The Chat entry removes that default so that Option+P goes to the plugin. Use /model to open the model picker.
In Claude Code, meta is the Option key on macOS and the Alt key on other keyboards. Every shortcut must reach Claude Code as a Meta key.
π for Option+P.Esc followed by e, E, or p.Ghostty (tested). Ghostty sends Option keys as Meta with no setup. For the Cmd keys, add these lines to the Ghostty config, then reload it:
keybind = super+e=esc:e
keybind = super+shift+e=esc:E
keybind = super+p=esc:p
Other terminals (not tested). Set Option to send Meta:
To use the Cmd keys too, map them in the terminal as the Ghostty lines do.
Run /config to change these settings:
/effort <level> and the model picker./model <name> and the model picker.CLAUDE_EFFORT variable. Use the plugin's footer text to see the model and level that requests use./compact.claude plugin validate .
claude --plugin-dir .
To load a local copy in every session, set CLAUDE_CODE_PLUGIN_DIRS to the folder in the env block of ~/.claude/settings.json.
MIT
hooks/register.tsx 199 lines1import type { EngineInterface as Engine, PluginOptions, Register } from 'claude-code'
2
3type Level = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
4type Direction = 'up' | 'down'
5type ModelEntry = { id: string; prefix: string; name: string; option: string; effort: boolean }
6
7const ORDER: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max']
8const MODELS: readonly ModelEntry[] = [
9 { id: 'claude-haiku-5-5', prefix: 'claude-haiku-5-5', name: 'Haiku 5.5', option: 'haiku', effort: true },
10 { id: 'claude-sonnet-5-5', prefix: 'claude-sonnet-5-5', name: 'Sonnet 5.5', option: 'sonnet', effort: true },
11 { id: 'claude-opus-5-5', prefix: 'claude-opus-5-5', name: 'Opus 5.5', option: 'opus', effort: true },
12 { id: 'claude-fable-5-1', prefix: 'claude-fable-5-1', name: 'Fable 5.1', option: 'fable', effort: true },
13]
14const FALLBACK: Level = 'high'
15const SAVED_LEVELS = 'savedPicks'
16const SAVED_MODEL = 'savedModel'
17
18const pickRef = { plugin: 'model-effort-shortcuts', key: 'pick' } as const
19const engineRef = { plugin: 'model-effort-shortcuts', key: 'engine' } as const
20const modelPickRef = { plugin: 'model-effort-shortcuts', key: 'modelPick' } as const
21const engineModelRef = { plugin: 'model-effort-shortcuts', key: 'engineModel' } as const
22const footerRef = { plugin: 'model-effort-shortcuts', key: 'footer' } as const
23
24const isLevel = (value: unknown): value is Level => typeof value === 'string' && (ORDER as readonly string[]).includes(value)
25const entryFor = (model: string) => MODELS.find(entry => model === entry.id || model.startsWith(entry.prefix))
26const sameModel = (a: string, b: string) => a === b || (entryFor(a) !== undefined && entryFor(a) === entryFor(b))
27
28export const register: Register = (on, options) => {
29 on('session.start', async ($, e, next) => {
30 const result = await next(e)
31 await $.state.set(pickRef, await loadSavedLevels($))
32 await $.state.set(engineModelRef, await $.session.model())
33 const saved = await $.store.get(SAVED_MODEL)
34 if (typeof saved === 'string' && entryFor(saved) && allowedModels(options).some(entry => sameModel(entry.id, saved))) {
35 await $.state.set(modelPickRef, saved)
36 }
37 await refreshFooter($)
38 return result
39 })
40
41 on('turn.step', async function* ($, e, next) {
42 if (e.agentId !== undefined) return yield* next(e)
43 await followEngineModel($)
44 if (isLevel(e.effort)) await followEngineLevel($, e.model, e.effort)
45 const target = (await $.state.get(modelPickRef)).value ?? e.model
46 const supportsEffort = entryFor(target)?.effort ?? isLevel(e.effort)
47 const level = supportsEffort ? await currentLevel($, target) : undefined
48 await refreshFooter($)
49 if (target === e.model && level === e.effort) return yield* next(e)
50 const { effort: _engineEffort, ...rest } = e
51 return yield* next(level === undefined ? { ...rest, model: target } : { ...rest, model: target, effort: level })
52 })
53
54 on('command.run', { command: 'effort' }, async ($, e, next) => {
55 const result = await next(e)
56 if (e.args.trim() !== '') {
57 await clearLevelPick($, await activeModel($))
58 await refreshFooter($)
59 }
60 return result
61 })
62
63 on('command.run', { command: 'model' }, async ($, e, next) => {
64 const result = await next(e)
65 if (e.args.trim() !== '') await clearModelPick($)
66 await followEngineModel($)
67 await refreshFooter($)
68 return result
69 })
70
71 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
72 const footer = (await $.state.get(footerRef)).value
73 const parts = footer ? [...e.props.modes, footer] : [...e.props.modes]
74 const { Box, Text } = $.ui.resolve(e)
75 return (
76 <Box marginRight={2}>
77 <Text dimColor> {parts.join(' · ')}</Text>
78 </Box>
79 )
80 })
81
82 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83 if (e.props.hasSurvey) return next(e)
84 const below = await next(e)
85 const { Box, Button } = $.ui.resolve(e)
86 return (
87 <Box>
88 <Box display="none">
89 <Button key="model-effort-shortcuts-up" label="raise effort" action="strip:jump9" onPress={() => shiftLevel($, options, 'up')} />
90 <Button key="model-effort-shortcuts-down" label="lower effort" action="strip:jump8" onPress={() => shiftLevel($, options, 'down')} />
91 <Button key="model-effort-shortcuts-model-up" label="next model" action="strip:jump7" onPress={() => shiftModel($, options, 'up')} />
92 <Button key="model-effort-shortcuts-model-down" label="previous model" action="strip:jump6" onPress={() => shiftModel($, options, 'down')} />
93 </Box>
94 {below}
95 </Box>
96 )
97 })
98}
99
100function levelRange(options: PluginOptions): Level[] {
101 const lowest = isLevel(options.lowest) ? ORDER.indexOf(options.lowest) : 0
102 const highest = isLevel(options.highest) ? ORDER.indexOf(options.highest) : ORDER.indexOf('xhigh')
103 return ORDER.slice(Math.min(lowest, highest), Math.max(lowest, highest) + 1)
104}
105
106function allowedModels(options: PluginOptions): ModelEntry[] {
107 return MODELS.filter(entry => options[entry.option] !== false)
108}
109
110function stepThrough<T>(all: readonly T[], allowed: readonly T[], rank: number, direction: Direction): T | undefined {
111 if (allowed.length === 0) return undefined
112 return direction === 'up'
113 ? allowed.find(item => all.indexOf(item) > rank) ?? allowed[0]
114 : [...allowed].reverse().find(item => all.indexOf(item) < rank) ?? allowed[allowed.length - 1]
115}
116
117async function shiftLevel($: Engine, options: PluginOptions, direction: Direction) {
118 const model = await activeModel($)
119 if (entryFor(model)?.effort === false) return
120 const target = stepThrough(ORDER, levelRange(options), ORDER.indexOf(await currentLevel($, model)), direction)
121 if (target === undefined) return
122 const picks = (await $.state.get(pickRef)).value ?? {}
123 await $.state.set(pickRef, { ...picks, [model]: target })
124 await $.store.set(SAVED_LEVELS, { ...(await loadSavedLevels($)), [model]: target })
125 $.ui.log(`Set to ${await refreshFooter($)}`)
126}
127
128async function shiftModel($: Engine, options: PluginOptions, direction: Direction) {
129 const current = entryFor(await activeModel($))
130 const target = stepThrough(MODELS, allowedModels(options), current ? MODELS.indexOf(current) : -1, direction)
131 if (target === undefined) return
132 const sessionModel = await $.session.model()
133 if (sameModel(target.id, sessionModel)) {
134 await clearModelPick($)
135 } else {
136 await $.state.set(modelPickRef, target.id)
137 await $.store.set(SAVED_MODEL, target.id)
138 }
139 $.ui.log(`Set to ${await refreshFooter($)}`)
140}
141
142async function activeModel($: Engine): Promise<string> {
143 return (await $.state.get(modelPickRef)).value ?? (await $.session.model())
144}
145
146async function currentLevel($: Engine, model: string): Promise<Level> {
147 const pick = (await $.state.get(pickRef)).value?.[model]
148 if (isLevel(pick)) return pick
149 const engine = (await $.state.get(engineRef)).value?.[model]
150 if (isLevel(engine)) return engine
151 const settings = await $.settings.read()
152 const perModel = settings.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined
153 const configured = perModel?.[model]?.effortLevel ?? settings.effortLevel
154 return isLevel(configured) ? configured : FALLBACK
155}
156
157async function followEngineModel($: Engine) {
158 const sessionModel = await $.session.model()
159 const seen = (await $.state.get(engineModelRef)).value ?? null
160 if (seen === sessionModel) return
161 await $.state.set(engineModelRef, sessionModel)
162 if (seen !== null) await clearModelPick($)
163}
164
165async function followEngineLevel($: Engine, model: string, level: Level) {
166 const engine = (await $.state.get(engineRef)).value ?? {}
167 const previous = engine[model]
168 if (previous === level) return
169 await $.state.set(engineRef, { ...engine, [model]: level })
170 if (previous !== undefined) await clearLevelPick($, model)
171}
172
173async function clearModelPick($: Engine) {
174 await $.state.set(modelPickRef, null)
175 await $.store.delete(SAVED_MODEL)
176}
177
178async function clearLevelPick($: Engine, model: string) {
179 const { [model]: _live, ...picks } = (await $.state.get(pickRef)).value ?? {}
180 await $.state.set(pickRef, picks)
181 const { [model]: _saved, ...saved } = await loadSavedLevels($)
182 await $.store.set(SAVED_LEVELS, saved)
183}
184
185async function loadSavedLevels($: Engine): Promise<Record<string, Level>> {
186 const raw = await $.store.get(SAVED_LEVELS)
187 if (!raw || typeof raw !== 'object') return {}
188 return Object.fromEntries(Object.entries(raw).filter((entry): entry is [string, Level] => isLevel(entry[1])))
189}
190
191async function refreshFooter($: Engine) {
192 const model = await activeModel($)
193 const entry = entryFor(model)
194 const name = entry?.name ?? model
195 const text = entry?.effort === false ? name : `${name} · effort: ${await currentLevel($, model)}`
196 if ((await $.state.get(footerRef)).value !== text) await $.state.set(footerRef, text)
197 return text
198}
199types/index.d.ts 12 lines1declare module 'claude-code' {
2 interface PluginState {
3 'model-effort-shortcuts': {
4 pick: Record<string, 'low' | 'medium' | 'high' | 'xhigh' | 'max'>
5 engine: Record<string, 'low' | 'medium' | 'high' | 'xhigh' | 'max'>
6 modelPick: string | null
7 engineModel: string | null
8 footer: string | null
9 }
10 }
11}
12