SLOPSHOPPER

model-dial

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

newpanebandcommandtoast
v0.1.0MITupdated 2026-10-11jmegan/claude-mods/model-dial
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-dial
│ ┃ Model & effort ✕ › fix the failing auth test and add an audit log call │ ┃ Model Effort │ ┃ o: pick-model-opus 1: pick-effort-low ⏺ Read(src/auth.ts) │ ┃ s: pick-model-sonnet 2: pick-effort-mediu ⎿ Read 6 lines │ ┃ h: pick-model-haiku 3: pick-effort-high ⏺ Update(src/auth.ts) │ ┃ 4: pick-effort-xhigh ⎿ Added 2 lines, removed 1 line │ ┃ 5: pick-effort-max ⏺ Bash(bun test) │ ┃ Esc closes · changes ⎿ 3 pass, 1 fail │ ┃ turn │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dial │ ⎿ model-dial: Opened the model and effort picker. │ │ model-opus model-sonnet model-haiku │ effort-low effort-medium effort-high effort-xhigh effort-max ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
model-opus model-sonnet model-haiku │ effort-low effort-medium effort-high effort-xhigh effort-max ⟨Claude Code's own drawing⟩
Pane · Model & effort
Model Effort o: pick-model-opus 1: pick-effort-low s: pick-model-sonnet 2: pick-effort-medium h: pick-model-haiku 3: pick-effort-high 4: pick-effort-xhigh 5: pick-effort-max Esc closes · changes wait for the cu
README

claude-mods

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.

ModWhat it does
model-dialClickable model and effort chips above the prompt, plus /dial [model] [effort].
quick-repliesClickable reply buttons above the prompt (Continue, Ask me, Try again, your own), /qr, and a skill that mines your history for suggestions.

Install

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.

Make it yours

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.

ModWhere your preferences live
model-dialThe Models option (userConfig), for example opus,sonnet,haiku.
quick-repliesA 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.

Make your own mod

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.

What you can build

You wantUse
A pane, panel or sidebar$.ui.open({ id, title }) and a ui.render hook on { component: 'Pane' }
A band above the promptA 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 callon('tool.call', { tool }, hook)
To change a prompton('prompt.submit', hook)
To change the system prompton('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.

Write a mod that anyone can personalize

Follow these rules for every mod, even one you build only for yourself. Then sharing it later takes no rework.

  1. Never hard-code a personal value. Names, paths, model lists, button text and hotkeys come from configuration, with a sensible default.
  2. Use 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.

  1. Use a local file for anything bigger. A list of buttons, rules or snippets belongs in a JSON file in the user's Claude config folder. Let a configPath option override the location. Commit a <mod>.example.json and never commit a real config.
  2. Fail soft. A bad config file keeps the defaults and shows a toast that names the problem. It never breaks the mod.
  3. Do not trust the project folder. A cloned repository can contain a config file. If a mod reads project files, make that opt-in and off by default, as quick-replies does with Project buttons.
  4. Read local data only on request. A skill or script that reads the user's history runs locally, skips anything that looks like a secret, and prints only what the user approves.
  5. Document the options. The mod's README lists every option and config field with its default.

Develop and check

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).

Share a mod

  1. Put the mod in a folder of this repository, or in a repository of its own.
  2. List it in .claude-plugin/marketplace.json:
   {
     "name": "<marketplace>",
     "owner": { "name": "<author>" },
     "plugins": [{ "name": "<mod>", "source": "./<mod>" }]
   }

For a repository that holds a single mod, set "source": "./".

  1. Run claude plugin validate . from the repository root.
  2. Give people the install line: /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.

Check this repository

claude plugin validate .
claude plugin test model-dial
claude plugin test quick-replies

Documentation

The 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.

License

MIT

Source 2 files
hooks/register.tsx 328 lines
1import { 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}
328
types/index.d.ts 18 lines
1export 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