Pick the model and the reasoning effort from one small pane or /mode. Does nothing until you pick something.

Pick the model and the reasoning effort from one small pane, or from /mode. It does nothing until you pick something: while no effort is chosen, every request goes out untouched.
/mode opens the pane (model: Fable, Opus, Sonnet, Haiku or Claude Code's default; effort: low, medium, high, xhigh, max or default)./mode model <alias> switches the model (fable, opus, sonnet, haiku, default) through Claude Code's own /model./mode effort <level> sets the effort of every request of the main conversation (subagents are left alone). The level is remembered across sessions; levels a model does not support are handled by Claude Code. It never writes your /config. If you later change the effort yourself with /effort, that newer choice wins and the mod drops its override. /mode effort default removes the override. A level that the optional decision-router mod put on the step is not mistaken for your /effort.mode.manual (true once you picked a model or an effort in the current session) for other mods to read.Works on its own, or together with the optional menu mod: then the menu lists "Mode" (for example Opus 5.5 · effort high) and shows this page in its pane. Picking a model also makes it your default for new sessions, because the mod switches through /model, which saves it to settings.json; the effort is never written to settings.
/plugin marketplace add danielmalka/malka-claude-mods
/plugin install mode@malka-claude-mods
/mode effort default
/plugin uninstall mode@malka-claude-mods
/mode effort default clears the remembered level. The model stays whatever you last chose with /model.
Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed); the mod's own state contract is types/index.d.ts, and types/menu.d.ts vendors the menu mod's contract (apiVersion 1). Then claude plugin test plugins/mode.
Escolha o modelo e o esforço de raciocínio num painel pequeno ou com /mode. Não faz nada até você escolher algo: sem esforço escolhido, as requisições seguem intactas.
/mode abre o painel (modelo: Fable, Opus, Sonnet, Haiku ou o padrão do Claude Code; esforço: low, medium, high, xhigh, max ou default)./mode model <alias> troca o modelo (fable, opus, sonnet, haiku, default) pelo /model do próprio Claude Code./mode effort <nível> define o esforço de cada requisição da conversa principal (subagentes ficam de fora), lembrado entre sessões; níveis que o modelo não suporta são tratados pelo Claude Code. Nunca escreve no seu /config; se você mudar o esforço com /effort, a sua escolha vence e o override é descartado. /mode effort default remove o override. Um nível que o mod opcional decision-router colocou no passo não é confundido com o seu /effort.mode.manual (verdadeiro quando você escolheu modelo ou esforço na sessão atual) para outros mods lerem.Funciona sozinho ou com o mod opcional menu (que lista "Mode" e mostra a página no seu painel). Escolher um modelo também o torna o seu padrão para novas sessões, pois o mod troca pelo /model, que o grava no settings.json; o esforço nunca é gravado nas configurações.
Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install mode@malka-claude-mods. Desinstalar: rode /mode effort default e então /plugin uninstall mode@malka-claude-mods.
hooks/register.tsx 183 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {} from '../types/menu'
5import type { Effort, MenuEntry } from '../types'
6import { modePage } from './mode-page'
7import { EFFORTS, isEffort, MODELS, prettyModel, summary } from './modes'
8
9const effort = atom({ plugin: 'mode', key: 'effort' } as const, null)
10const sessionEffort = atom({ plugin: 'mode', key: 'sessionEffort' } as const, null)
11const sessionModel = atom({ plugin: 'mode', key: 'sessionModel' } as const, null)
12const manual = atom({ plugin: 'mode', key: 'manual' } as const, false)
13// Foreign ref: the optional `menu` mod owns it (apiVersion 1); undefined = no menu.
14const menuPanel = { plugin: 'menu', key: 'panel' } as const
15// Foreign ref: decision-router's own routed effort; absent plugin = null.
16const routerEffort = { plugin: 'decision-router', key: 'effort' } as const
17const menuEntryRef = { plugin: 'mode', key: 'menuEntry' } as const
18
19// What the engine itself ran the last main-loop step at, to notice the user's own /effort.
20let seenEffort: Effort | undefined
21let seenModel: string | undefined
22
23const PANE = 'mode-settings'
24const MENU_PANE = 'mods-menu'
25
26// Published for the optional menu; a failure here never blocks the switch itself.
27async function writeEntry($: EngineInterface) {
28 const entry: MenuEntry = {
29 apiVersion: 1,
30 title: 'Mode',
31 summary: summary(await read($, sessionModel), (await read($, effort)) ?? (await read($, sessionEffort))),
32 order: 3,
33 }
34 await $.state.set(menuEntryRef, entry).catch(() => undefined)
35}
36
37const markManual = ($: EngineInterface) => update($, manual, () => true)
38
39// The effort every request of the main loop is sent with; null clears it. Never writes the user's /config.
40async function setEffort($: EngineInterface, level: Effort | 'default') {
41 const next = level === 'default' ? null : level
42 await update($, effort, () => next)
43 if (next === null) await $.store.delete('effort')
44 else await $.store.set('effort', next)
45 await markManual($)
46 await writeEntry($)
47 $.ui.toast(`Effort: ${level}`)
48}
49
50// Switches the session's model through Claude Code's own `/model`, so /model, the status line and the requests agree.
51async function setModel($: EngineInterface, alias: string) {
52 const before = await $.session.model()
53 const ran = await $.command.run({ command: 'model', args: alias })
54 const id = await $.session.model()
55 if (id === before) {
56 $.ui.toast(ran.text?.trim() || 'Model unchanged')
57 return
58 }
59 await update($, sessionModel, () => id)
60 await markManual($)
61 await writeEntry($)
62 $.ui.toast(`Model: ${prettyModel(id)}`)
63}
64
65// The session's own effort level before any request. First match wins: a /config row, the effortLevel setting, CLAUDE_EFFORT.
66async function loadSessionEffort($: EngineInterface) {
67 const found: unknown[] = []
68 try {
69 found.push((await $.config.list()).find(r => /effort/i.test(r.key))?.value)
70 } catch {}
71 try {
72 found.push(((await $.settings.read()) as { effortLevel?: unknown }).effortLevel)
73 } catch {}
74 found.push(await $.env.get('CLAUDE_EFFORT'))
75 const level = found.find(isEffort)
76 if (level !== undefined) await update($, sessionEffort, () => level)
77}
78
79async function page($: EngineInterface, e: any, hasBack: boolean) {
80 const tell = (what: string) => (error: unknown) => $.ui.toast(`${what}: ${String(error)}`)
81 return modePage($.ui.resolve(e), {
82 hasBack,
83 model: await read($, sessionModel),
84 level: (await read($, effort)) ?? (await read($, sessionEffort)),
85 onModel: alias => setModel($, alias).catch(tell('Could not switch the model')),
86 onEffort: level => setEffort($, level).catch(tell('Could not set the effort')),
87 // the menu mod handles this press itself (ui.press on this plugin's key); the closure keeps the Button pressable
88 onBack: () => undefined,
89 })
90}
91
92export const register: Register = on => {
93 on('session.start', async ($, e, next) => {
94 // Nothing is overridden until the user picks: only a stored level is read back.
95 // A new session starts its own baseline: the last one's engine level is not the user's choice here.
96 seenEffort = undefined
97 seenModel = undefined
98 await $.command
99 .register({ name: 'mode', description: 'Pick the model and the reasoning effort', argumentHint: '[model <alias>|effort <level|default>]' })
100 .catch(() => undefined)
101 try {
102 const saved = await $.store.get('effort')
103 if (isEffort(saved)) await update($, effort, () => saved)
104 await loadSessionEffort($).catch(() => undefined)
105 await $.session.model().then(id => update($, sessionModel, () => id)).catch(() => undefined)
106 await update($, manual, () => false)
107 await writeEntry($)
108 } catch {}
109 return next(e)
110 })
111
112 on('command.run', { command: 'mode' }, async ($, e) => {
113 const [what, value] = e.args.trim().toLowerCase().split(/\s+/)
114 try {
115 if (what === 'model') {
116 if (value !== 'default' && !MODELS.some(m => m.alias === value)) return { text: `Model: ${MODELS.map(m => m.alias).join(', ')} or default.` }
117 // /model cannot be run from inside a command.run hook (it would wait on this turn): run it right after.
118 $.clock.after(0, () => void setModel($, value).catch(error => $.ui.toast(`Could not switch the model: ${String(error)}`)))
119 return { text: `Switching the model to ${value}.` }
120 }
121 if (what === 'effort') {
122 if (value !== 'default' && !isEffort(value)) return { text: `Effort: ${EFFORTS.join(', ')} or default.` }
123 await setEffort($, value as Effort | 'default')
124 return { text: `Effort: ${value}.` }
125 }
126 } catch (error) {
127 return { text: `Mode could not be changed: ${String(error)}` }
128 }
129 await $.ui.open({ id: PANE, title: 'Mode', focus: true, closeOnEscape: true, rows: 18, columns: 48 })
130 return { text: 'Mode settings are open.' }
131 })
132
133 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => page($, e, false))
134
135 // Inside the menu's pane (apiVersion 1): drawn only when the menu is on this panel.
136 on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e, next) => {
137 const { value } = await $.state.get(menuPanel)
138 if (value !== 'mode') return next(e)
139 return page($, e, true)
140 })
141
142 // The chosen effort rides on every model request of the main loop; unset = the request goes down untouched.
143 on('turn.step', async function* ($, e, next) {
144 if (e.agentId !== undefined) return yield* next(e)
145 let level: Effort | null = null
146 try {
147 level = await read($, effort)
148 } catch {}
149 // A level the router put on this step is not the engine's: do not mistake it for the user's /effort.
150 let routed: unknown = null
151 try {
152 routed = (await $.state.get(routerEffort)).value
153 } catch {}
154 const engineEffort = routed !== null && e.effort === routed ? undefined : e.effort
155 // The session's own level and model, as the engine had them, before ours replaces the level.
156 let changed = false
157 // The engine's level moved on the same model since the last step: the user ran /effort or /config.
158 // That is a newer choice than our override, so ours is dropped.
159 if (e.model !== seenModel) seenEffort = undefined
160 seenModel = e.model
161 if (isEffort(engineEffort)) {
162 if (level !== null && seenEffort !== undefined && engineEffort !== seenEffort) {
163 level = null
164 await update($, effort, () => null).catch(() => undefined)
165 await $.store.delete('effort').catch(() => undefined)
166 $.ui.toast(`Effort: following your /effort ${engineEffort}`)
167 changed = true
168 }
169 seenEffort = engineEffort
170 }
171 if (isEffort(engineEffort) && engineEffort !== (await read($, sessionEffort).catch(() => null))) {
172 await update($, sessionEffort, () => engineEffort).catch(() => undefined)
173 changed = true
174 }
175 if (e.model !== (await read($, sessionModel).catch(() => null))) {
176 await update($, sessionModel, () => e.model).catch(() => undefined)
177 changed = true
178 }
179 if (changed) await writeEntry($)
180 return yield* next(level !== null && e.effort !== undefined ? { ...e, effort: level } : e)
181 })
182}
183hooks/mode-page.tsx 50 lines1import type { Effort } from '../types'
2import { EFFORTS, MODELS, modelFamily } from './modes'
3
4type Ui = { Box: any; Text: any; Button: any }
5
6// The Mode page: model and effort, one item per row so the arrow keys walk it top to bottom.
7export function modePage(
8 { Box, Text, Button }: Ui,
9 p: {
10 hasBack: boolean
11 model: string | null
12 level: Effort | null // the level shown as chosen: the user's, else the session's
13 onModel: (alias: string) => unknown
14 onEffort: (level: Effort | 'default') => unknown
15 onBack: () => unknown
16 },
17) {
18 const option = (key: string, label: string, isOn: boolean, onPress: () => unknown) => (
19 <Box key={`row-${key}`} gap={1}>
20 <Button key={key} plain label={`${isOn ? '●' : '○'} ${label}`} dimColor={!isOn} onPress={onPress} />
21 </Box>
22 )
23 const heading = (text: string) => (
24 <Box key={`h-${text}`} marginTop={1}>
25 <Text bold>{text}</Text>
26 </Box>
27 )
28 const family = modelFamily(p.model)
29 return (
30 <Box flexDirection="column" paddingX={1}>
31 <Box justifyContent="space-between">
32 <Text bold color="claude">
33 ◆ Mode
34 </Text>
35 {p.hasBack && <Button key="mods-back" plain dimColor hotkey="b" label="← back" onPress={p.onBack} />}
36 </Box>
37 <Text dimColor>Pick the model and the reasoning effort.</Text>
38 {heading('Model')}
39 {MODELS.map(m => option(`model-${m.alias}`, m.label, m.alias === family, () => p.onModel(m.alias)))}
40 <Button key="model-default" plain dimColor label="↺ Claude Code's default" onPress={() => p.onModel('default')} />
41 {heading('Effort')}
42 {EFFORTS.map(l => option(`effort-${l}`, l, l === p.level, () => p.onEffort(l)))}
43 <Button key="effort-default" plain dimColor label="↺ default" onPress={() => p.onEffort('default')} />
44 <Box marginTop={1}>
45 <Text dimColor>↑↓ move · enter select · esc close</Text>
46 </Box>
47 </Box>
48 )
49}
50hooks/modes.ts 27 lines1import type { Effort } from '../types'
2
3export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
4
5// The models the pane offers, by the alias `/model` takes.
6export const MODELS = [
7 { alias: 'fable', label: 'Fable' },
8 { alias: 'opus', label: 'Opus' },
9 { alias: 'sonnet', label: 'Sonnet' },
10 { alias: 'haiku', label: 'Haiku' },
11] as const
12
13// `claude-opus-5-5` -> `opus`; null for an id that is not a Claude model's.
14export const modelFamily = (id: string | null): string | null => id?.match(/^claude-([a-z]+)-/i)?.[1]?.toLowerCase() ?? null
15
16// `claude-opus-5-5` as `Opus 5.5`; a name that is not a Claude model id stays as it is.
17export function prettyModel(id: string): string {
18 const match = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?/i)
19 if (match === null) return id
20 const [, family = '', major = '', minor] = match
21 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor === undefined ? '' : `.${minor}`}`
22}
23
24export const isEffort = (v: unknown): v is Effort => typeof v === 'string' && EFFORTS.includes(v as Effort)
25
26export const summary = (model: string | null, level: Effort | null) => `${model === null ? '' : `${prettyModel(model)} · `}effort ${level ?? 'default'}`
27types/index.d.ts 21 lines1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2
3// Menu contract, apiVersion 1: an optional `menu` mod lists this entry.
4export type MenuEntry = { apiVersion: 1; title: string; summary: string; order: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 mode: {
9 menuEntry: MenuEntry
10 // true once the user picked a model or an effort in this session (read by other mods, e.g. a router)
11 manual: boolean
12 // the effort the user chose; null = none, no override is sent
13 effort: Effort | null
14 // the effort the session itself runs at, as last seen
15 sessionEffort: Effort | null
16 // the session's model id, e.g. claude-opus-5-5
17 sessionModel: string | null
18 }
19 }
20}
21