A clickable model and effort switcher above the prompt, plus /dial for keyboard switching.

Small, practical Claude Code mods by John Egan, published as the egan-dev-mods plugin marketplace. Each mod changes one thing about how Claude Code looks or behaves, and each mod is configurable, so your preferences stay on your machine.
| Mod | What it does |
|---|---|
model-dial | Clickable model and effort chips above the prompt, plus /dial [model] [effort]. |
quick-replies | Clickable reply buttons above the prompt (Continue, Ask me, Try again, your own), /qr, and a skill that mines your history for suggestions. |
Type these at the prompt of a Claude Code terminal session:
/plugin marketplace add jmegan/claude-mods
/plugin install model-dial@egan-dev-mods
/plugin install quick-replies@egan-dev-mods
Or use the shell:
claude plugin marketplace add jmegan/claude-mods
claude plugin install model-dial@egan-dev-mods --scope user
claude plugin install quick-replies@egan-dev-mods --scope user
Mods that have settings show a screen at install time. Change them later in the /plugin menu.
No mod in this repository holds a personal value. Each mod reads your preferences from your own machine, so you never edit the mod to personalize it, and updates never overwrite your choices.
| Mod | Where your preferences live |
|---|---|
model-dial | The Models option (userConfig), for example opus,sonnet,haiku. |
quick-replies | A local file, quick-replies.json, in your Claude config folder ($CLAUDE_CONFIG_DIR, else ~/.claude), or the path in the Config file option. Start from quick-replies.example.json. |
A mod is a Claude Code plugin whose main file is a hooks module. Three files make the smallest mod:
my-mod/
├── .claude-plugin/plugin.json name, version, description, options
├── hooks/hooks.json { "modules": ["./register.tsx"] }
└── hooks/register.tsx export const register: Register = (on, options) => { ... }
A hook has the shape ($, e, next):
$ is the engine interface. Each call has a noun and a method, such as $.ui.toast(text).e is the event's input, a plain frozen value.next(e) runs the plugins beneath and then the engine's own behavior. A hook that returns without calling next answers for itself. next({ ...e, x }) rewrites what the rest sees.The module runs in its own environment, with no DOM and no Node. $ reaches everything outside it.
| You want | Use |
|---|---|
| A pane, panel or sidebar | $.ui.open({ id, title }) and a ui.render hook on { component: 'Pane' } |
| A band above the prompt | A ui.render hook on { component: 'AbovePrompt' } |
| A status line entry or a toast | $.ui.status(text) or $.ui.toast(text) |
| To block or rewrite a tool call | on('tool.call', { tool }, hook) |
| To change a prompt | on('prompt.submit', hook) |
| To change the system prompt | on('prompt.compose', hook) |
| A slash command | $.command.register({ name, description }) in session.start, answered by a command.run hook |
| Values that a drawing reads | $.state (one session) or $.store (across sessions), typed by a contract in types/index.d.ts |
| Timers, files, processes, model calls | $.clock, $.fs, $.process, $.model |
Study the two mods here for working examples: model-dial is a band with a command, and quick-replies adds config files, hotkeys, a skill and a script.
Follow these rules for every mod, even one you build only for yourself. Then sharing it later takes no rework.
userConfig for a few simple options. Declare each option in plugin.json, then read it from the options argument of register. Give every option a default, and a list of options for a picker: "userConfig": {
"models": {
"type": "string",
"title": "Models",
"description": "Comma-separated model aliases, in order.",
"default": "opus,sonnet,haiku"
}
}
Claude Code stores the values in the user's settings, under pluginConfigs. They stay out of the repository.
configPath option override the location. Commit a <mod>.example.json and never commit a real config.quick-replies does with Project buttons.claude --plugin-dir ./my-mod # load the mod for one session; saved edits reload it
claude --debug # shows why a hook was skipped or a tree was refused
claude plugin validate ./my-mod # reads the manifest and module; reports what the engine refuses
claude plugin test ./my-mod # runs the mod's *.test.ts files against the engine
To edit an installed mod live, add your clone as the marketplace (claude plugin marketplace add ~/path/to/claude-mods), edit the source, then run /reload-plugins.
Write at least one *.test.ts for the behavior the mod adds. A test receives the user's options with test(name, { options }, body), so test both the defaults and a custom config.
For editor types, a mod that Claude Code loads from a folder gets declarations in .claude-plugin/types/. Run tsc -p <mod folder> to type-check it. That folder is generated, so it stays out of Git (see .gitignore).
.claude-plugin/marketplace.json: {
"name": "<marketplace>",
"owner": { "name": "<author>" },
"plugins": [{ "name": "<mod>", "source": "./<mod>" }]
}
For a repository that holds a single mod, set "source": "./".
claude plugin validate . from the repository root./plugin install <mod> --marketplace <owner>/<repo>.Before you make a repository public, read your last commit and your history for personal paths, tokens and email addresses. A public repository exposes every commit, not just the current files.
claude plugin validate .
claude plugin test model-dial
claude plugin test quick-replies
userConfig and the CLI commandspluginConfigsThe function-hook API (on, $, the events and the elements) has no web page. Claude Code writes its type declarations next to a mod it loads from a folder, in .claude-plugin/types/claude-code/index.d.ts. Search that file for an event or noun, because each entry has a doc comment and an example.
hooks/register.tsx 328 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { Dial, Effort } from '../types'
5
6const dial = atom({ plugin: 'model-dial', key: 'dial' } as const, {
7 model: null,
8 effort: null,
9 pendingModel: null,
10 pendingEffort: null,
11 isEffortIgnored: false,
12})
13
14const PANE = 'model-dial'
15const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
16const EFFORT_SHORT: Record<Effort, string> = { low: 'low', medium: 'med', high: 'high', xhigh: 'xhigh', max: 'max' }
17const EFFORT_BLURB: Record<Effort, string> = {
18 low: 'quick answers, lowest cost',
19 medium: 'everyday work',
20 high: 'harder problems',
21 xhigh: 'deep, multi-step work',
22 max: 'everything it has',
23}
24// Effort heats up from green to red, so the level reads at a glance.
25const EFFORT_INK: Record<Effort, string> = {
26 low: '#7FB069',
27 medium: '#C9B458',
28 high: '#E5A050',
29 xhigh: '#E07A55',
30 max: '#D9534F',
31}
32const MODEL_INK: Record<string, string> = { opus: '#D97757', sonnet: '#6A9BCC', haiku: '#7FB069', fable: '#B48EAD' }
33const MODEL_BLURB: Record<string, string> = {
34 opus: 'most capable',
35 sonnet: 'fast, strong at code',
36 haiku: 'quickest, cheapest',
37 fable: 'largest',
38}
39const CHIP_TEXT = '#1B1B1B'
40// Cells one row needs past the model chips: the separator, five effort chips with their
41// gaps, and `n/a` (plus 2 for a pending chip's `…`). The `after turn` note shows only if it fits.
42const EFFORT_COLUMNS = 5 + EFFORTS.reduce((n, level) => n + EFFORT_SHORT[level].length + 3, 0) + 4 + 2
43const NOTE_COLUMNS = 11
44
45type El = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
46
47const isBlank = (tree: unknown): boolean => {
48 if (tree === null || tree === undefined) return true
49 if (typeof tree === 'string') return tree.trim() === ''
50 if (typeof tree !== 'object') return false
51 const { type, children } = tree as { type?: string; children?: unknown[] }
52 return (type === 'Text' || type === 'Box') && (children ?? []).every(isBlank)
53}
54
55const familyOf = (model: string | null): string | null =>
56 model?.toLowerCase().match(/opus|sonnet|haiku|fable/)?.[0] ?? null
57
58const titleCase = (word: string): string => word.charAt(0).toUpperCase() + word.slice(1)
59
60/** `claude-opus-5-5` → `Opus 5.5`; an alias → its family; anything else as given. */
61const displayName = (model: string | null): string => {
62 if (model === null) return 'default'
63 const family = familyOf(model)
64 if (family === null) return model
65 const version = model.match(/(\d+)[-.](\d+)/)
66 return version ? `${titleCase(family)} ${version[1]}.${version[2]}` : titleCase(family)
67}
68
69const asEffort = (value: unknown): Effort | null =>
70 typeof value === 'string' && (EFFORTS as readonly string[]).includes(value) ? (value as Effort) : null
71
72/** Reads a typed token as an effort level, accepting `med` and `x` shorthands. */
73const parseEffort = (token: string): Effort | null => {
74 const t = token.toLowerCase()
75 if (t === 'med' || t === 'mid') return 'medium'
76 if (t === 'x' || t === 'xh') return 'xhigh'
77 return asEffort(t)
78}
79
80const parseModels = (raw: unknown): string[] => {
81 const list = typeof raw === 'string' ? raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean) : []
82 return list.length > 0 ? list : ['opus', 'sonnet', 'haiku']
83}
84
85const setModel = async ($: EngineInterface, alias: string): Promise<void> => {
86 await update($, dial, d => ({ ...d, pendingModel: alias }))
87 try {
88 await $.command.run({ command: 'model', args: alias })
89 // Our own command.run hook skips a run this plugin made, so read the result back here.
90 const model = await $.session.model()
91 await update($, dial, d => ({ ...d, model, pendingModel: null, isEffortIgnored: false }))
92 } catch (err) {
93 await update($, dial, d => ({ ...d, pendingModel: null }))
94 $.ui.toast(`model-dial: /model ${alias} failed: ${err instanceof Error ? err.message : String(err)}`)
95 }
96}
97
98const setEffort = async ($: EngineInterface, level: Effort): Promise<void> => {
99 await update($, dial, d => ({ ...d, pendingEffort: level }))
100 try {
101 await $.command.run({ command: 'effort', args: level })
102 await update($, dial, d => ({ ...d, effort: level, pendingEffort: null }))
103 } catch (err) {
104 await update($, dial, d => ({ ...d, pendingEffort: null }))
105 $.ui.toast(`model-dial: /effort ${level} failed: ${err instanceof Error ? err.message : String(err)}`)
106 }
107}
108
109/** One segment of a segmented control: a filled pill when on, dim text when off. */
110const Chip = (
111 { Button, Text }: El,
112 props: { id: string; label: string; ink: string; isOn: boolean; isPending: boolean; onPress: () => void },
113) => {
114 const { id, label, ink, isOn, isPending, onPress } = props
115 if (isPending) {
116 return (
117 <Button key={id} plain onPress={onPress}>
118 <Text color={ink} bold underline>
119 {` ${label}… `}
120 </Text>
121 </Button>
122 )
123 }
124 if (isOn) {
125 return (
126 <Button key={id} plain onPress={onPress}>
127 <Text backgroundColor={ink} color={CHIP_TEXT} bold>
128 {` ${label} `}
129 </Text>
130 </Button>
131 )
132 }
133 return (
134 <Button key={id} plain onPress={onPress}>
135 <Text color={ink} dimColor hover={{ scope: `chip-${id}`, dimColor: false, bold: true }}>
136 {` ${label} `}
137 </Text>
138 </Button>
139 )
140}
141
142const modelChips = (el: El, $: EngineInterface, d: Dial, models: readonly string[]) => {
143 const live = familyOf(d.model)
144 const pending = familyOf(d.pendingModel)
145 return models.map(alias => {
146 const family = familyOf(alias) ?? alias
147 const isOn = live === family && pending === null
148 return Chip(el, {
149 id: `model-${alias}`,
150 label: titleCase(alias),
151 ink: MODEL_INK[family] ?? '#9A9A9A',
152 isOn,
153 isPending: pending === family && live !== family,
154 onPress: () => void setModel($, alias),
155 })
156 })
157}
158
159const effortChips = (el: El, $: EngineInterface, d: Dial) =>
160 EFFORTS.map(level =>
161 Chip(el, {
162 id: `effort-${level}`,
163 label: EFFORT_SHORT[level],
164 ink: EFFORT_INK[level],
165 isOn: d.effort === level && d.pendingEffort === null && !d.isEffortIgnored,
166 isPending: d.pendingEffort === level && d.effort !== level,
167 onPress: () => void setEffort($, level),
168 }),
169 )
170
171/** Reads the live model and effort from the engine's own sources. */
172const syncFromEngine = async ($: EngineInterface): Promise<void> => {
173 const model = await $.session.model()
174 const settings = await $.settings.read()
175 const effort = asEffort(settings.effortLevel)
176 await update($, dial, d => ({ ...d, model, effort: effort ?? d.effort }))
177}
178
179export const register: Register = (on, options) => {
180 const models = parseModels(options.models)
181
182 on('session.start', async ($, e, next) => {
183 await $.command.register({
184 name: 'dial',
185 description: 'Switch model and effort (for example /dial sonnet high), or open the picker',
186 argumentHint: '[model] [effort]',
187 })
188 await syncFromEngine($)
189 return next(e)
190 })
191
192 // Every /model run, typed or pressed, refreshes the live model afterwards.
193 on('command.run', { command: 'model' }, async ($, e, next) => {
194 const out = await next(e)
195 const model = await $.session.model()
196 await update($, dial, d => ({ ...d, model, pendingModel: null, isEffortIgnored: false }))
197 return out
198 }).catch(($, e, next) => next(e))
199
200 on('command.run', { command: 'effort' }, async ($, e, next) => {
201 const out = await next(e)
202 const typed = parseEffort(e.args.trim().split(/\s+/)[0] ?? '')
203 const settings = await $.settings.read()
204 const effort = typed ?? asEffort(settings.effortLevel)
205 await update($, dial, d => ({ ...d, effort: effort ?? d.effort, pendingEffort: null }))
206 return out
207 }).catch(($, e, next) => next(e))
208
209 // The first request of each main turn names the model and effort it really sends;
210 // a request with no effort means the model ignores it.
211 on('turn.step', async function* ($, e, next) {
212 if (e.agentId === undefined && e.index === 0) {
213 const effort = asEffort(e.effort)
214 const isEffortIgnored = e.effort === undefined
215 await update($, dial, d => ({ ...d, model: e.model, effort: effort ?? d.effort, isEffortIgnored }))
216 }
217 return yield* next(e)
218 })
219
220 on('command.run', { command: 'dial' }, async ($, e) => {
221 const tokens = e.args.trim().split(/\s+/).filter(Boolean)
222 if (tokens.length === 0) {
223 await $.ui.open({ id: PANE, title: 'Model & effort', focus: true, closeOnEscape: true, rows: 9 })
224 return { text: 'Opened the model and effort picker.' }
225 }
226 const said: string[] = []
227 for (const token of tokens) {
228 const level = parseEffort(token)
229 const alias = models.find(m => m.startsWith(token.toLowerCase()))
230 if (level !== null) {
231 void setEffort($, level)
232 said.push(`effort ${level}`)
233 } else if (alias !== undefined) {
234 void setModel($, alias)
235 said.push(titleCase(alias))
236 } else {
237 return { text: `Unknown model or effort: ${token}. Models: ${models.join(', ')}. Effort: ${EFFORTS.join(', ')}.` }
238 }
239 }
240 return { text: `Switching to ${said.join(' · ')}.` }
241 })
242
243 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
244 if (e.props.hasSurvey) return next(e)
245 const el = $.ui.resolve(e)
246 const { Box, Text } = el
247 const d = await read($, dial)
248 const modelColumns = models.reduce((n, alias) => n + alias.length + 3, 0)
249 const rowColumns = modelColumns + EFFORT_COLUMNS
250 const isOneRow = e.props.bodyColumns >= rowColumns
251 const hasNoteRoom = !isOneRow || e.props.bodyColumns >= rowColumns + NOTE_COLUMNS
252 const isQueued = e.props.isWorking && hasNoteRoom && (d.pendingModel !== null || d.pendingEffort !== null)
253
254 // Other mods' bands render beneath this one; dropping next() would hide them.
255 const beneath = await next(e)
256 const mine = (
257 <Box key="model-dial" flexDirection={isOneRow ? 'row' : 'column'} columnGap={2}>
258 <Box columnGap={1}>{modelChips(el, $, d, models)}</Box>
259 {isOneRow && <Text dimColor>│</Text>}
260 <Box columnGap={1}>
261 {effortChips(el, $, d)}
262 {d.isEffortIgnored && <Text dimColor italic>n/a</Text>}
263 {isQueued && <Text dimColor italic>after turn</Text>}
264 </Box>
265 </Box>
266 )
267 return isBlank(beneath) ? mine : <Box flexDirection="column">{mine}{beneath}</Box>
268 })
269
270 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
271 const { Box, Text, Button } = $.ui.resolve(e)
272 const d = await read($, dial)
273 const live = familyOf(d.pendingModel ?? d.model)
274 const nameWidth = Math.max(...models.map(m => m.length))
275 const effort = d.pendingEffort ?? d.effort
276 const used = new Set<string>()
277 const hotkeyFor = (alias: string): string | undefined => {
278 const key = [...alias].find(c => /[a-z]/.test(c) && !used.has(c))
279 if (key !== undefined) used.add(key)
280 return key
281 }
282
283 return (
284 <Box flexDirection="row" columnGap={4}>
285 <Box flexDirection="column">
286 <Text bold>Model</Text>
287 {models.map(alias => {
288 const family = familyOf(alias) ?? alias
289 const isOn = live === family
290 return (
291 <Button key={`pick-model-${alias}`} plain hotkey={hotkeyFor(alias)} onPress={() => void setModel($, alias)}>
292 <Text color={isOn ? MODEL_INK[family] ?? 'claude' : undefined} bold={isOn}>
293 {`${isOn ? '●' : '○'} ${titleCase(alias).padEnd(nameWidth)}`}
294 </Text>
295 <Text dimColor>{` ${MODEL_BLURB[family] ?? ''}`}</Text>
296 </Button>
297 )
298 })}
299 </Box>
300 <Box flexDirection="column">
301 <Text bold>Effort</Text>
302 {EFFORTS.map((level, i) => {
303 const isOn = effort === level && !d.isEffortIgnored
304 return (
305 <Button key={`pick-effort-${level}`} plain hotkey={String(i + 1)} onPress={() => void setEffort($, level)}>
306 <Text color={isOn ? EFFORT_INK[level] : undefined} bold={isOn}>
307 {`${isOn ? '●' : '○'} ${level.padEnd(6)}`}
308 </Text>
309 <Text dimColor>{` ${EFFORT_BLURB[level]}`}</Text>
310 </Button>
311 )
312 })}
313 <Text dimColor>Esc closes · changes wait for the current turn</Text>
314 </Box>
315 </Box>
316 )
317 })
318
319 // A switch with a warm prompt cache re-sends the whole context; say what that costs.
320 on('classic.PostModelSwitch', async ($, e, next) => {
321 if (e.prompt_cache_warm && e.estimated_cache_write_usd >= 0.01) {
322 const k = Math.round(e.context_tokens / 1000)
323 $.ui.toast(`Now on ${displayName(e.to_model)} · re-caching ${k}k tokens ≈ $${e.estimated_cache_write_usd.toFixed(2)}`)
324 }
325 return next(e)
326 }).catch(($, e, next) => next(e))
327}
328types/index.d.ts 18 lines1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2
3/** What the dial shows: the live model and effort, and a change still queued behind a turn. */
4export type Dial = {
5 model: string | null
6 effort: Effort | null
7 pendingModel: string | null
8 pendingEffort: Effort | null
9 /** True once a main request went out with no effort: the live model ignores it. */
10 isEffortIgnored: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'model-dial': { dial: Dial }
16 }
17}
18